Nginx Proxy for Docker Containers

Photo credit: Unsplash: seemoris

Update (2017-01-27): I’ve got some tweaks to this configuration. Check out this more recent post for the diff.

While there are few ancillary items I’ll cover after this post, we’ve got everything we need to put a nice web server in-front of the Docker hosted Ghost blog.

nginx

I just love nginx. It is a great web server, and the configuration is powerful, logical, and approachable. I’m going to run through the basics of running nginx in Docker, and then go into how I configure it specifically for my website.

nginx in Docker

Within my repository of file for setting up my server is a folder dedicated to the web image that is based on nginx. Here is the Dockerfile in that folder:

FROM nginx
RUN mkdir /etc/nginx/ssl
COPY ssl /etc/nginx/ssl
COPY nginx.conf /etc/nginx/nginx.conf
COPY www /usr/share/nginx/www
COPY archive /usr/share/nginx/archive

You can see here that I start with the latest nginx image from the Docker hub. I then setup the folders I need and copy in my specific configuration and content. There isn’t much else here since the bulk of the work is done by the nginx image from Docker hub.

The static content

The four items that get copied into my nginx image are the self-signed SSL certificates I use. My nginx.conf file (more on that to come.) Finally my www and archive folders, which simply contain the static file content for www.danivovich.com and my website archive (archive.danivovich.com.)

The nginx config

This is the real meat and potatoes of the setup. I’ll spare you the boring standard nginx config and focus on my server blocks.

There is a little http scope configuration I set:

  proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=GHOST:100m inactive=24h max_size=2g;
  proxy_cache_key "$scheme$host$request_uri";
  proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504;
  proxy_http_version 1.1;

  upstream ghost_blog {
    server ghost:2368;
  }

The cache setup is pretty standard, and the upstream server definition uses the ghost entry in my hosts file, which will be put there by Docker when I link this container to Ghost container with the alias ghost.

The www and archive server blocks are as follows:

server {
    listen 80;
    server_name archive.danivovich.com;

    location = /atom {
      rewrite ^ http://www.danivovich.com/old_atom.xml permanent;
    }

    location = /rss {
      rewrite ^ http://www.danivovich.com/rss permanent;
    }

    location / {
      root   /usr/share/nginx/archive;
      gzip_static on;
      expires 1d;
      add_header Cache-Control public;
      index  index.html index.htm;
    }
    error_page 404 /404.html;
  }

  server {
    listen 80 default_server;
    server_name danivovich.com www.danivovich.com;

    if ($host != 'www.danivovich.com' ) {
      rewrite ^ http://www.danivovich.com$request_uri? permanent;
    }

    location = /atom {
      rewrite ^ http://www.danivovich.com/old_atom.xml permanent;
    }

    location /rss {
      proxy_cache GHOST;
      proxy_cache_valid 200 30m;
      proxy_cache_valid 404 1m;
      proxy_ignore_headers "Set-Cookie";
      proxy_hide_header "Set-Cookie";
      proxy_ignore_headers "Cache-Control";
      proxy_hide_header "Cache-Control";
      proxy_hide_header "Etag";
      expires 15m;
      proxy_pass http://ghost_blog;
    }

    location / {
      root   /usr/share/nginx/www;
      gzip_static on;
      expires 1d;
      add_header Cache-Control public;
      index  index.html index.htm;
    }
    error_page 404 /404.html;
  }

There isn’t much interesting here. I do force the hostname to www.danivovich.com when danivovich.com is accessed directly. I do some work with the rss and old atom feeds from my old blog to present useful information to my users and keep some consistent rss access. The biggest thing happening here is proxying the rss requests down to Ghost, since that is the only real source of feed data within my website.

The Ghost proxy

The configuration for the blog domain is the truly interesting part of this configuration. Let’s take a look:

server {
    listen 80;
    listen 443 ssl spdy;
    ssl_certificate        /etc/nginx/ssl/self-ssl.crt;
    ssl_certificate_key    /etc/nginx/ssl/self-ssl.key;
    ssl_session_cache shared:SSL:20m;
    ssl_session_timeout 10m;

    server_name blog.danivovich.com;

    location /rss {
      rewrite ^ http://www.danivovich.com/rss permanent;
    }

    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;

    location ~* \.(jpg|jpeg|svg|png|gif|ico|css|js|eot|woff)$ {
      proxy_cache GHOST;
      proxy_cache_valid 200 1d;
      proxy_cache_valid 404 5m;
      proxy_ignore_headers "Cache-Control";
      access_log off;
      expires 1d;
      proxy_pass http://ghost_blog;
    }

    location ~ ^/(?:ghost) {
      expires 0;
      add_header Cache-Control "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0";
      proxy_pass http://ghost_blog;
    }

    location ~ ^/(?:p/) {
      expires 0;
      add_header Cache-Control "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0";
      proxy_pass http://ghost_blog;
    }

    location / {
      proxy_cache GHOST;
      proxy_cache_valid 200 1h;
      proxy_cache_valid 404 5m;
      proxy_ignore_headers "Set-Cookie";
      proxy_hide_header "Set-Cookie";
      proxy_ignore_headers "Cache-Control";
      proxy_hide_header "Cache-Control";
      proxy_hide_header "Etag";
      expires 30m;
      proxy_pass http://ghost_blog;
    }
  }

This server block defines the main proxy to Ghost. It listens on 80 and 443 with HTTP2 and my self-signed certificate. I block access to the Ghost rss feed and redirect to the one off my main domain (which in turn proxies back to Ghost, but since it does so directly, it doesn’t get blocked by this proxy.)

I use some pretty standard nginx proxy settings, then move on to some specific location handling. The first makes sure that I send some decent caching headers to the client browser for any static assets that don’t change very often. I also turn off access logging for these assets.

The next two location blocks, location ~ ^/(?:ghost) and location ~ ^/(?:p/)handle the Ghost admin interface and the preview URLs that Ghost generates. These blocks ensure that nothing here is cached, since its the admin interface.

Finally we have location / for any requests that didn’t match a more specific configuration above. This caches within nginx, all of the public Ghost content, and sets some caching headers so the client browsers will cache as well. I also remove and hide cookie and caching headers from Ghost so that my own configuration will drive all the caching rules. Ultimately it would be nice if these headers, especially the Etag, could determine when and for how long nginx does its caching, but the reverse proxy caching in nginx is simple and not fully compliant to the http caching standard, and doesn’t play nicely with Ghost’s caching headers out-of-the-box.

Pitfall

I struggled a bit with the caching aspect of the nginx config. At one point I tried to simplify it, and things did not go well. I ended up in a state where nginx was caching Ghost admin interface and API calls, which wasn’t immediately apparent but did cause a lot of strange behavior in the Ghost editor, and ultimately caused me to lose a post I was writing at the time. I did find this blog post which really helped me re-work the configuration to get to the point I’m at now.

Systemd

Finally a simple systemd unit configuration to launch my web container.

[Unit]
Description=Run web
After=ghost.service
Requires=ghost.service

[Service]
Restart=always
RestartSec=30s
ExecStartPre=-/usr/bin/docker kill web
ExecStartPre=-/usr/bin/docker rm web
ExecStart=/usr/bin/docker run --rm --name web --link ghost:ghost -p 80:80 -p 443:443 web
ExecStop=/usr/bin/docker kill web
ExecStartPost=-/usr/bin/docker rm web

[Install]
WantedBy=multi-user.target

Here I make sure to link in the Ghost container named ghost, aliasing it to ghost, which is the hostname my nginx setup is expecting to find the upstream server at. I map port 80 and 443 through to the host CoreOS system so that this container is available to the world.

Those are the basics for the nginx portion of my website stack. There is still more to come in this series examining the tech behind my website relaunch. Check back soon!