Running Discourse behind Nginx (and a small rant about docker)

Running Discourse behind Nginx comes with several benefits. Primarily you get to bypass dockers terrible network stack, which injects NAT rules into iptables to expose ports to your containers, but you also get more control over the public facing webserver.

Another big issue I have with the docker stack is, by default it doesn't give containers routable IPv6 addresses. This means if you're forwarding ports into your docker container, lets say HTTP and HTTPS (ports 80 and 443), and your main host has an IPv6 address it gets translated into an IPv4 address once it reaches the container. This breaks the X-Forwarded-For header and in my experience sets it to the IP of the docker0 bridge, an IPv4 address.

So, if you block admin logins[1] in Discourse and add an IPv6 address to the allow list it wont work. You wont be able to login. Whoops.

Doh!

CC by 2.0 image by hobvias sudoneighm

The solution (there probably is a better one, but this is what I came up with) is pretty simple. Just put Discourse behind Nginx and stop forwarding ports all together. Not long do you get control over your firewall but the external server is now something you control.

But how do I expose Discourse now?! Simple, via sockets on the file system. They even have a template setup for it!

I've put together a server block that can be used for this purpose below, but any basic proxy_pass setup will work.

All together to get this working we just need to do the below:

  1. Add the web.socketed.template.yml template to your container
  2. Remove port forwarding in container config
  3. Rebuild container
  4. Setup nginx in front and deploy[2]

Note: this config assumes you're running a version of Nginx that can do httpd2 which was added in v1.9.5.

# Set discourse upstream paths
upstream discourse-http {
    server unix:/var/discourse/shared/web-only/nginx.http.sock;
}
upstream discourse-https {
    server unix:/var/discourse/shared/web-only/nginx.https.sock;
}

# Rewrite http requests to https
server {
    listen 80 default_server;

    rewrite ^ https://discourse.example.com$request_uri? permanent;
}

# Our HTTPS Server
# We don't really need much config here since everything gets passed to the docker instance
# We're just here to forward requests

server {

    listen 443 ssl http2;
    # listen on IPv6 as well
    listen [::]:443 ssl http2;

    # certs sent to the client in SERVER HELLO are concatenated in ssl_certificate
    # we're using Discourse's default SSL location here but it can be changed to something more standard like /etc/ssl/
    ssl_certificate /var/discourse/shared/web-only/ssl/ssl.crt;
    ssl_certificate_key /var/discourse/shared/web-only/ssl/ssl.key;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:50m;
    ssl_session_tickets off;

    # Diffie-Hellman parameter for DHE ciphersuites, recommended 2048 bits
    ssl_dhparam /var/discourse/shared/web-only/ssl/dhparams.pem;

    # intermediate configuration. tweak to your needs.
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS';
    ssl_prefer_server_ciphers on;

    # HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months)
    add_header Strict-Transport-Security max-age=15768000;

    # OCSP Stapling ---
    # fetch OCSP records from URL in ssl_certificate and cache them
    ssl_stapling on;
    ssl_stapling_verify on;

    ## verify chain of trust of OCSP response using Root CA and Intermediate certs
    ssl_trusted_certificate /path/to/root_CA_cert_plus_intermediates;

    resolver <IP DNS resolver>;

    server_name _ ;
    server_tokens off;

    keepalive_timeout 65;

    # maximum file upload size (keep up to date when changing the corresponding Discourse)
    client_max_body_size 10m;

    # Pass everything to Discourse
    location / {
        proxy_set_header    Host                 $http_host;
        proxy_set_header    X-Real-IP            $remote_addr;
        proxy_set_header    X-Forwarded-For      $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto    $scheme;
        # Discourse unix socket
        proxy_pass $scheme://discourse-$scheme;
    }
}

Let us know how you get on. I've had this solution deployed for work purposes for several months now and haven't seen a single downside.

Footnotes


  1. This feature was added in Discourse 1.1. ↩︎

  2. You can also optionally remove the SSL configuration from Discourse entirely since sockets are done locally ↩︎

James Loh

I'm a Sysadmin for a web solutions company deploying clouds across the globe. I learn new things every day.

Australia @itsjloh jloh jloh