Migrating Existing Services to Docker - Part Four

Hello again. Let's recap what we've done so far.

  1. Migrated an existing separate service (Teamspeak3) to Docker. [Part 1]
  2. Migrated a basic HTTP server stack (HAProxy and Nginx) to Docker. [Part 2]
  3. Changed that existing web stack so that it was HTTPS-only. [Part 3]

So what's next? Today, I'll go over how I migrated a Node.JS application over to Docker, adding it to the existing web stack, and using Nginx as a reverse proxy to add HTTPS support.

When we left off, we had HAProxy and Nginx containers configured such that we have multiple web containers, each of which are proxied to from HAProxy using TCP Passthrough. In order to have our reverse proxy work properly, we'll have to create a new Docker image for it (based on the one we currently use), and we'll have to add to the HAProxy configuration and docker-compose.yml file. Of course, we'll also need to have a service to run behind the proxy. I've chosen to use Ghost (the software on which this blog is running) as our example here, but feel free to swap that out for any service you need.

1. Creating the reverse proxy image

For this step, I created a custom Dockerfile that takes the nginx:alpine image that we're using for our Nginx containers, adds the program envplate, then invokes envplate on the Nginx configuration file before starting Nginx.

Dockerfile

FROM nginx:alpine
RUN apk update && apk add ca-certificates && update-ca-certificates && \
		wget -O /usr/local/bin/ep https://github.com/kreuzwerker/envplate/releases/download/v0.0.8/ep-linux && chmod +x /usr/local/bin/ep
COPY nginx-proxy.conf /etc/nginx/nginx.conf
CMD [ "/usr/local/bin/ep", "-v", "/etc/nginx/nginx.conf", "--", "/usr/sbin/nginx", "-g", "daemon off;" ]

You might also notice that the Dockerfile copys nginx-proxy.conf to the image directly, rather than us linking the configuration file later using volumes in docker-compose.yml. We can do this because of the extra step I've taken of adding envplate to the system. What envplate does is parses through a file, replacing tokens with the values of environment variables. This works perfectly with Docker, since we can define environment variables in docker-compose.yml.

1a. nginx-proxy.conf

For the most part, nginx-proxy.conf is going to look exactly like our existing nginx.conf file, which we're already using for our existing Nginx containers. Feel free to copy that to wherever you've created your Dockerfile, and make sure that it's named nginx-proxy.conf. There is one relatively small change we need to make, though. In the server section of our config, under the SSL configuration, we're going to replace the line root /usr/share/nginx/html; with the following location section.

nginx-proxy.conf (excerpt)

    location / {
      proxy_set_header        Host $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;

      proxy_pass http://${APP_HOST:-app}:${APP_HOST_PORT:-8080};
      proxy_read_timeout 90;
    }

The proxy_set_header lines are just setting headers that the proxied service can look for so it knows that the requests are being proxied. With our existing setup, the X-Real-IP and X-Forwarded-For headers will always show the IP address of our HAProxy container, as there is no way for us to add in the proper IP address from the remote computer while using TCP Passthrough.

The proxy_pass line is the most important part of the configuration. It is instructing Nginx to proxy all requests through to the host specified in the environment variable APP_HOST, using the port specified in the environment variable APP_HOST_PORT, and also specifies a default host of app and port of 8080.

1b. Building the Docker container

Once we've created the Dockerfile and the nginx-proxy.conf file, we can build our image. In order to do that, as shown before with the Teamspeak3 image we built, you simply need to run docker build -t [username]/nginx-proxy:v1 . from within the folder that you created the configuration files. This will create a Docker image that we can now use in our docker-compose.yml file.

2. docker-compose.yml

Our next step is to edit docker-compose.yml to create our Ghost instance and an instance of our new Nginx proxy image. For this, we will need to add two new services to our docker-compose.yml file, and add one more link to the existing HAProxy service, as shown below.

docker-compose.yml

  ghost:
    image: zzrot/alpine-ghost
    environment:
      PROD_DOMAIN: https://blog.akpwebdesign.com/
      NODE_ENV: production
    volumes:
      - /home/austin/websites/blog.akpwebdesign.com/content/:/var/lib/ghost/ # This volume is where Ghost will store things like your themes and image uploads.
    expose:
      - 2368

  blog.akpwebdesign.com:
    image: [username]/nginx-proxy:v1
    restart: always
    environment:
      - APP_HOST=ghost     # These environment variables will be swapped into our nginx-proxy.conf file by envplate.
      - APP_HOST_PORT=2368
    expose:
      - 443
    volumes:
      - /home/austin/websites/dhparam.pem:/etc/nginx/dhparam.pem:ro
      - /etc/letsencrypt/live/blog.akpwebdesign.com/:/usr/share/nginx/ssl/
      - /etc/letsencrypt/archive/blog.akpwebdesign.com/:/usr/share/archive/blog.akpwebdesign.com/
    links:
      - ghost:ghost

  haproxy:
    image: haproxy:alpine
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /srv/websites/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
      - /srv/websites/haproxy/errors/:/usr/local/etc/haproxy/errors/:ro
      - /dev/log:/dev/log
    links:
      - akpwebdesign.com
      - customersite.com:cust01
      - blog.akpwebdesign.com:blog # This is the link we added to our HAProxy service.

I've used an existing Ghost image I found, zzrot/alpine-ghost, which I've found to be a nice, clean Ghost image, ready to go out of the box. You can see the environment variables listed in the environment section of our Ghost container configuration. As an aside, you can actually use this reverse proxy configuration to proxy anything, simply by filling these values out correctly. For instance, if you need to proxy requests to an external server, simply make APP_HOST the URL of the server you need, and APP_HOST_PORT port 80 (or whatever other port your external service is running on).

2a. Let's Encrypt

Don't forget that we still need to generate a LetsEncrypt SSL certificate for our domain or subdomain. This can be done in the same fashion as the last tutorial, and as long as you've been following along, the certificate should be ready to go in the proper location.

3. haproxy.cfg

Now we only have one thing left to do, and that's to edit our haproxy.cfg file to include the new subdomain and backend configuration. For this, we'll need to add a new redirect line to the www frontend, a new use_backend line to the ssl frontend, and of course, a new backend to point to our Nginx proxy server (which we've aliased to blog in the docker-compose.yml file above).

haproxy.cfg (excerpts)

[...]

#######################
# Non-Secure Frontend #
#######################
frontend www  
  bind 0.0.0.0:80
  option http-server-close
  option forwardfor

  # Redirect AKP Web Design sites to SSL.
  redirect scheme https code 301 if { hdr(host) -i akpwebdesign.com } !{ ssl_fc } !{ path_beg /.well-known/acme-challenge }
  redirect scheme https code 301 if { hdr(host) -i blog.akpwebdesign.com } !{ ssl_fc } !{ path_beg /.well-known/acme-challenge } # New redirect for blog.

  # Redirect customer sites to SSL.
  redirect scheme https code 301 if { hdr(host) -i customersite.com } !{ ssl_fc } !{ path_beg /.well-known/acme-challenge }

  # LetsEncrypt backend, no SSL.
  use_backend le if { path_beg /.well-known/acme-challenge }

  # Default backend.
  default_backend le

################
# SSL Frontend #
################
frontend ssl  
  mode tcp
  option tcplog

  bind 0.0.0.0:443

  option socket-stats
  tcp-request inspect-delay 5s
  tcp-request content accept if { req_ssl_hello_type 1 }

  # AKP Web Design sites
  use_backend akp if { req_ssl_sni -i akpwebdesign.com }
  use_backend blog if { req_ssl_sni -i blog.akpwebdesign.com } # New use_backend for blog.

  # Customer sites
  use_backend customer if { req_ssl_sni -i customersite.com }

  # No default_backend, as certs would be broken anyway.
  # default_backend null

[...]

#########################
# blog.akpwebdesign.com #
#########################
backend blog  
  mode tcp

  # maximum SSL session ID length is 32 bytes.
  stick-table type binary len 32 size 30k expire 30m

  acl clienthello req_ssl_hello_type 1
  acl serverhello rep_ssl_hello_type 2

  # use tcp content accepts to detects ssl client and server hello.
  tcp-request inspect-delay 5s
  tcp-request content accept if clienthello

  # no timeout on response inspect delay by default.
  tcp-response content accept if serverhello

  stick on payload_lv(43,1) if clienthello

  # Learn on response if server hello.
  stick store-response payload_lv(43,1) if serverhello

  option ssl-hello-chk

  server web1 blog:443

5. Starting our new containers

This is actually the easiest step. Make sure you're in the folder where your docker-compose.yml file is located, and run docker-compose up -d. This will start any new containers needed, and restart the HAProxy container once all the other containers are running. This command will also restart any containers that need to be updated, so you can use this if you have changed your configuration files, so you don't need to bring your entire stack down if you've only changed one container.

That's the end of this tutorial. Once your containers are all up and running, you should be able to navigate to the URL you've specified above (assuming you've already got DNS set up for it), and see your fresh new Ghost installation!

Austin Peterson

Read more posts by this author.