Docker Tutorial Part 3: Kamal with Alternative Proxy

In this document we discuss using Kamal orchestration with an external reverse proxy, such as nginx or haproxy. We also discuss opening additional ports on docker containers orchestrated with Kamal.

This document assumes you have a working knowledge of using Kamal for orchestration of Docker containers, as described in Docker Tutorial Part 2: Orchestration with Kamal.

Using an Alternative Proxy

By default, Kamal uses kamal-proxy during deployments to perform health checks on startup, open ports 80 and 443 on your server(s), and allow for a seamless transition from old containers to new.

But sometimes we need to run a different reverse proxy + load balancer like nginx or haproxy, which already do the same thing, so we have no need for kamal-proxy.

We can disable kamal-proxy using a proxy: directive in our deploy.yml file, like so:

servers:
  web:
    hosts:
      - 10.10.10.1
      - 10.10.10.2
    proxy: false

Caution

When doing above, be sure to comment out or remove any other proxy: directives in your deploy.yml.

Opening Ports

Since kamal-proxy is disabled, we will need to use an options: stanza to publish: the ports we want to open. The following would open port 80 on the public interface proxying to port 80 on the container, same for ports 443, 8080 and 4430:

servers:
  web:
    hosts:
      - 10.10.10.1
      - 10.10.10.2
    proxy: false
    options:
      publish:
        - 80:80
        - 443:443
        - 8080:8080
        - 4430:4430

Resolving port conflicts

With the above config we see that the initial kamal setup is fine, but subsequent deploys will fail due to “port already allocated” errors. This is because Kamal will try to start the new container before shutting down the old one, which kamal-proxy used to handle for “seamless deployments”, but is no longer running.

The solution is to run a hook script which will stop containers before Kamal starts the new ones: https://kamal-deploy.org/docs/hooks/overview/

The following pre-app-boot hook script issues docker container stop on the containers first.

.kamal/hooks/pre-app-boot:

#!/bin/python3
#
# Kamal hook script 'pre-app-boot' to try to stop all containers prior to
# kamal starting the new ones.  This is for when you need to listen on multiple
# ports using options: publish: and start getting "port already allocated" errors.
#

import os, subprocess, datetime

# Open a logfile.
f = open('/var/log/pre-app-boot.log', "a+")

# Try to stop containers before kamal boots the app.
hosts = os.environ['KAMAL_HOSTS'].split(',')
for host in hosts:
    x = str(datetime.datetime.now())
    f.write(x + ': stopping containers on ' + host + ' ... ')
    result = subprocess.run(['ssh', host, 'docker container stop $(docker container ls -q)'],
                            text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if result.returncode != 0:
        f.write('error: docker container stop failed\n')

    else:
        f.write('success\n')

f.close()

Applying Group Sizes

An issue with above is deploys will by default block until all containers have stopped, causing a downtime which grows exponentially with the number of hosts. The solution for this is to use the boot: option to set group sizes.

For example, say you have 4 hosts and want to deply them in “sets” of 2 at a time, with a 10 second pause between. You can add a boot: stanza to your config, like so:

servers:
  web:
    hosts:
      - 10.10.10.1
      - 10.10.10.2
    proxy: false
    options:
      publish:
        - 80:80
        - 443:443
        - 8080:8080
        - 4430:4430
boot:
 limit: 2
 wait: 10

This tells Kamal to deploy 2 at a time, so the script above also runs/stops 2 at a time. This way you always have (the other) 2 hosts in production to pick up traffic and deploy seamlessly.

More details in the official docs here: https://kamal-deploy.org/docs/configuration/booting/

Setting Environment Variables per-server

You might also have to pass custom --env variables to the docker run command. This can be done with tags. For example say you need to pass the IP address as an environment variable. You add tags by adding them to the server IP addresses separated by a colon. To modify our existing deploy.yml it looks like this:

servers:
  web:
    hosts:
      - 10.10.10.1: 10.10.10.1
      - 10.10.10.2: 10.10.10.2

Note

Although we are using the IP addresses in this example, tags can have any value.

We then add an env: stanza which uses the tags to fill in the variables for those specific hosts. Note too, we also add a clear: tag for a custom environment variable that will be applied globally to all hosts.

env:
  clear:
    MY_GLOBAL_HOST_VAR: true
  tags:
    10.10.10.1:
      MY_HOST_IP: 10.10.10.1
    10.10.10.2:
      MY_HOST_IP: 10.10.10.2

Putting it All Together

The full config.yml:

# Deploy to these servers.
servers:
  web:
    hosts:
    # Tags are done in the form of IP: <tag>, we use the tag here as also the IP address,
    # so we can pass custom ENV vars containing specific IPs to each docker run command
    # (see below)
    - 10.10.10.1: 10.10.10.1
    - 10.10.10.2: 10.10.10.2
  proxy: false
  options:
    publish:
      - 80:80
      - 443:443
      - 8080:8080
      - 4430:4430

boot:
  limit: 2
  wait: 10

env:
  clear:
    MY_GLOBAL_HOST_VAR: true
  tags:
    10.10.10.1:
      MY_HOST_IP: 10.10.10.1
    10.10.10.2:
      MY_HOST_IP: 10.10.10.2