Deploying Trac with Nginx and Gunicorn

I very recently re-deployed the Trac application on my server. The first time I did this, I used the included tracd server, but I had a very hard time configuring it on a non-root path with Nginx. So I decided to use Gunicorn, which is also reported to give better performance (is it true? I did not test).

The configuration is a front-end server running Nginx coupled with a back-end server running Gunicorn + Trac and the database which will be PostgreSQL. Static files will be deported at the end to the front-end server for performance. I will not go too much into details in the process of installing the components, as they were packaged for the distribution I use (ArchLinux) and my environment did not need a strong separation into a virtual environment. So these steps are rather easy and better explained on the official website.

Setting up the Trac environment

First of all, let’s setup the trac environment. We create a user on the back-end server that will run the Gunicorn + Trac services and get permission on the content. With this approach, it is easier to manage permissions and handle administration tasks on the projects. This user also needs an access to the database.  The next step is to create the Trac project. We use the home directory as our workspace.

# useradd -m -s /usr/bin/false -U trac    # This will create /home/trac
# passwd -l trac
# psql -U postgres postgres
postgres=# CREATE USER trac;
postgres=# CREATE DATABASE trac OWNER = trac;
postgres=# \q
# su -s /usr/bin/bash - trac
$ mkdir repo   # Note: now we are in /home/trac with the trac user
$ trac-admin repo/PROJECT initenv
Project Name [My Project]> ...
Database connection string > postgres://trac@localhost/trac?schema=PROJECT
$ trac-admin repo/PROJECT permission add admin TRAC_ADMIN

Configure Gunicorn

Now, we are ready to deploy. We need to expose the WSGI application of Trac to Gunicorn, which is basically trac.web.main.dispatch_request. There is just a small tweak to do in the environment, as Gunicorn moves the REMOTE_USER header to HTTP_REMOTE_USER which Trac doesn’t expect. This is needed for authentication to work. So here is the script, to put into /home/trac/tracwsgi.py or somewhere else you like.

import os
import sys

os.environ['TRAC_ENV'] = '/home/trac/repo/PROJECT' # Single Project
# os.environ['TRAC_ENV_PARENT_DIR'] = '/home/trac/repo' # Multi Projects
os.environ['PYTHON_EGG_CACHE'] = '/home/trac/eggcache'

import trac.web.main

def application(environ, start_response):
    environ['REMOTE_USER'] = environ.get('HTTP_REMOTE_USER')
    return trac.web.main.dispatch_request(environ, start_response)

Don’t forget then to create the appropriate egg cache directory:

$ mkdir eggcache

You may also choose to delete the line in the Python script if your eggs are already unpacked by your package manager when installed. Then, we must configure Gunicorn, for example in /home/trac/gunicorn.py:

import os.path

bind = "0.0.0.0:9000" # Change this to your liking ("127.0.0.1:8000" for example)
chdir = os.path.dirname(os.path.realpath(__file__)) # Or the path to tracwsgi.py if different
errorlog="-"
loglevel="info"
proc_name="trac-gunicorn"
enable_stdio_inheritance=True
forwarded_allow_ips = "x.x.x.x" # The IP of the front-end server if not localhost, needed for SSL

Finally, we need a Systemd service file (trac-gunicorn.service):

[Unit]
Description=Trac (GUnicorn)
 
[Service]
Type=forking
User=trac
Group=trac
RuntimeDirectory=trac
PIDFile=%t/trac/gunicorn.pid
WorkingDirectory=/home/trac
ExecStart=/usr/bin/gunicorn-python2 -p %t/trac/gunicorn.pid -D -c /home/trac/gunicorn.py tracwsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
ExecStop=/bin/kill -s TERM $MAINPID
PrivateTmp=true
 
[Install]
WantedBy=multi-user.target

Inside the project configuration (repo/PROJECT/conf/trac.ini), you may want to change the option logging/log_type to stderr and adjust the log_level so that Trac logs to journald. And we’re good to go for the backend.

# systemctl start trac-gunicorn
# systemctl status -l trac-gunicorn
# systemctl enable trac-gunicorn

Configure Nginx for Single Project

The configuration here is rather simple. It is assumed that Trac will be reachable from http[s]://www.example.com/prefix:

http {
    ...
    server {
        ...
        location ^~ /prefix {
            # IP of the back-end
            proxy_pass       http://x.x.x.x:9000;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $http_host;
            proxy_set_header SCRIPT_NAME /prefix;
            proxy_set_header REMOTE_USER $remote_user;
            proxy_set_header X-Forwarded-Proto $scheme;
            # To get authentication to work
            location ~ /prefix/login {
                auth_basic            "Trac";
                auth_basic_user_file /path/to/htpasswd;
                # This directive is not yet inherited (coming soon)
                proxy_pass       http://x.x.x.x:9000;
            }
            # Placeholder for static files
        }
    }
}

After reloading Nginx, you should be able to get into Trac with your browser! If you prefer to force your users to authenticate before accessing the site, remove the nested location bloc (/prefix/login) and place the auth directives in the parent bloc (/prefix). You can of course also use other kinds of authentication mechanisms that work with Nginx (I use LDAP).

Let’s now offload the static files to the front-end. On the back-end server, collect the files:

# su -s /usr/bin/bash - trac
$ trac-admin repo/PROJECT deploy statics

Inside the created statics folder, there is a directory called htdocs, containing all the static files. This is the folder we need to transfer to the front-end (for example using tar and scp). Let’s say we want to have the statics in /www/assets. The easiest way would be to place the htdocs folder into /www/assets/prefix and rename that folder to chrome. That way, the folder structure will match the URLs (e.g. prefix/chrome/common/js/trac.js). In case it is not possible (or you don’t want to) you’ll have to play with the rewrite action to make the URLs point to the right file path. Once done, we need to direct Nginx to catch every request to prefix/chrome and return a local file. Add these lines inside the location ^~ /prefix bloc where the placeholder comment shows:

            location ~ /prefix/chrome {
                root /www/assets;
                expires 5d;
            }