Running Django on a subpath

Most of us been there, only some of us succeeded. I can say that I officially belong to the latter group now. After hours of research, I came to a somewhat crazy solution in terms of how to run Django applications on a subpath. Let me get some context around this, and we’ll be moving at a fast pace.

Despite the trends and advantages of nginx over Apache, I still had to stick with Apache due to being unable to affect the production server configuration. Because of the tremendous amount of system dependencies, the Django project in question lies within a Vagrant box (which essentially has an identical setup to the production machine). During development, the goal was to start this Vagrant box which runs the Django app on a subpath, forward the port being used by Apache inside the box to the host machine, twist plot here, the host machine is actually another VM running in KVM, which exposes this forwarded port to the outside world on a subpath too. So it actually is, subpath concatenation! Or something like that…

    +---------------+
    |               |
    | OUTSIDE WORLD |
    |               |
    +---------------+
                |
                |
            +-------------------+
            | ROUTER forwarding |
            | port 5983 to the  |
            | internal KVM VM   |
            +-------------------+
                            |
                            |
                        +----------------------------+
                        | KVM VM on port 5983 and    |
                        | subpath /vbox001 proxying  |
                        | to localhost:8080(vagrant) |
                        +----------------------------+
                                                 |
                                                 |
                                             +-----------------------+
                                             | Vagrant box serving   |
                                             | Django app on port 80 |
                                             | on subpath /mydjapp   |
                                             +-----------------------+

So let’s start from the outside, going towards the inner Django app. The KVM virtual machine is the host of the Vagrant box. In my Vagrantfile, I specified to forward port 80 (from the Vagrant box), to port 8080 on the host machine (KVM VM):

config.vm.network :forwarded_port, host: 8080, guest: 80

The KVM virtual machine is running Apache with the following virtual host dedicated to proxying requests from port 5983, and location /vbox001, to port 8080 (the Vagrant box).

<VirtualHost *:5983>
    ProxyPreserveHost On

    <Location "/vbox001">
        ProxyPass http://localhost:8080
        ProxyPassReverse http://localhost:8080
        ProxyPassReverseCookieDomain djangoapp.domain external.domain
        RequestHeader set X-SCRIPT-NAME /vbox001
        RequestHeader set X-SCHEME http
    </Location>
</VirtualHost>

It’s just a regular reverse proxy config, but what’s interesting is that all incoming requests will get two additional headers attached here by Apache, X-SCRIPT-NAME and X-SCHEME. These headers must be forwarded explicitly, otherwise we would lose the information they contain, and the server towards which we’re proxying requests would have no idea that the request came in through the /vbox001 subpath, nor about whether it’s running on http or https.

The ProxyPassReverseCookieDomain param is present here, so that it changes the cookie domain for all requests, from the internal to the external domain. Cookies would otherwise use the ServerName specified by the inner Apache server (which config we will see below). In my case, external.domain was just an IP address, but that depends on whether you have a registered domain name or not, so change it according to your setup.

Now that we have the setup of our outer Apache server, let’s go inside the Vagrant box, and see the config of the Apache instance running there:

<VirtualHost *:80>
    # just to note, this is the domain to which cookies will be assigned to
    # normally, and the above mentioned `ProxyPassReverseCookieDomain`
    # changes it to the external domain. Otherwise the browser would ignore
    # them, because the domains wouldn't match (the browser sees the
    # application running on `external.domain`)
    ServerName djangoapp.domain
    ServerAdmin webmaster@localhost

    ProxyPreserveHost On

    <Location "/mydjapp">
        ProxyPass http://127.0.01:8000
        ProxyPassReverse http://127.0.0.1:8000
        RequestHeader append X-SCRIPT-NAME /mydjapp
        RequestHeader edit X-SCRIPT-NAME ", /" "/"
    </Location>

    Alias /static/ /var/www/mydjapp/staticroot/
    <Directory /var/www/mydjapp/staticroot/>
        Order allow,deny
        Allow from all
    </Directory>
</VirtualHost>

So once again, this is another reverse proxy, which will forward all incoming requests on path /mydjapp, to the locally running (gunicorn, uwsgi or whatever server you use). But the real fun starts here, with the following two directives:

RequestHeader append X-SCRIPT-NAME /mydjapp
RequestHeader edit X-SCRIPT-NAME ", /" "/"

They are kinda very hackish, but I had no better idea, if someone has a better solution please enlighten me. Remember the outer Apache config already attached two additional headers that we use to track the subpath and scheme under which we serve our app. Because we’re using another subpath here, we have to concatenate these two paths. So when the request got here, it contained a header that looked like:

X-SCRIPT-NAME /vbox001

Then the first directive:

RequestHeader append X-SCRIPT-NAME /mydjapp

appends the second subpath to it, so we get:

X-SCRIPT-NAME /vbox001, /mydjapp

However, the append docs state that: “The request header is appended to any existing header of the same name. When a new value is merged onto an existing header it is separated from the existing header with a comma. This is the HTTP standard way of giving a header multiple values.” So we need to remove that comma and whitespace with:

RequestHeader edit X-SCRIPT-NAME ", /" "/"

So at the end we get:

X-SCRIPT-NAME /vbox001/mydjapp

For actual explanation how exactly the RequestHeader directive works, check out: http://httpd.apache.org/docs/2.2/mod/mod_headers.html#requestheader

At the end, we need to set up a quirky wsgi.py file:

import os

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mydjapp.settings.dev")

from django.core.wsgi import get_wsgi_application
_application = get_wsgi_application()

def application(environ, start_response):
    script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
    if script_name:
        environ['SCRIPT_NAME'] = script_name
        path_info = environ['PATH_INFO']
        if path_info.startswith(script_name):
            environ['PATH_INFO'] = path_info[len(script_name):]

    scheme = environ.get('HTTP_X_SCHEME', '')
    if scheme:
        environ['wsgi.url_scheme'] = scheme

    return _application(environ, start_response)

Within the wsgi file, we wrap the application function with another function, which essentially extracts the headers we forwarded (HTTP_X_SCRIPT_NAME, HTTP_X_SCHEME), and updates the SCRIPT_NAME and PATH_INFO wsgi environment variables. Straight from the docs, these two variables are used for:

SCRIPT_NAME

The initial portion of the request URL’s “path” that corresponds to the application object, so that the application knows its virtual “location”. This may be an empty string, if the application corresponds to the “root” of the server.

PATH_INFO

The remainder of the request URL’s “path”, designating the virtual “location” of the request’s target within the application. This may be an empty string, if the request URL targets the application root and does not have a trailing slash.

So we set SCRIPT_NAME to /vbox001/mydjapp and make sure that PATH_INFO does not contain /vbox001/mydjapp, only the eventual path that comes after /vbox001/mydjapp, for instance /admin.

Your Django settings file does NOT need to use the FORCE_SCRIPT_NAME config param at all. Just make sure, in order to serve the static files correctly, the actual STATIC_URL param must be set to use the absolute url:

STATIC_URL = 'http://external.domain:5983/vbox001/static/'

And finally, the URL config also doesn’t need any special care, so the following:

url(r'^admin/', include(admin.site.urls)),

would be totally valid and shall work.

That would be all, your Django app should be up and running on: http://external.domain:5983/vbox001/mydjapp/