How to Self-Host Services with Docker Swarm

01 January 2022

5 minute read

Self-hosting is great as you are in control of your own data. It is also more cost-effective than paying a monthly subscription for a handful of small services which you will only use a few times a month. And using docker is even better as it makes it very easy to run multiple applications off the same server without having to think about port allocation.

Stack summary

The server which I am running my applications is a VPS with 1 vCPU and 2GB RAM, and costs me about $10AUD a month including backups. It's not that big of a machine, but is more than enough to handle the workloads I throw at it.

Below is a list of management services that are hosted off the server:

  • Docker in swarm mode
  • Traefik for the reverse proxy
  • Authelia for SSO. This sits behind traefik
  • Portainer to easily manage applications via a GUI
  • acme-dns to manage ACME challenges for letsencrypt

External services used:

  • Let's Encrypt for TLS certificates
  • Cloudflare DNS - DNS only. I don't really use the CDN feature as it adds a lot of latency (at least that's the case on the free tier).

Services which I deploy:

  • Vaultwarden - a lightweight version of Bitwarden
  • Termpad - a private paste-bin
  • Transmission - a BitTorrent client
  • Inbucket - a catch-all email server. Useful for testing and signing up to dodgy sites
  • A few other personal apps

Initialising the stack

I am using docker in swarm mode, which allows docker to automatically manage docker-compose stacks, instead of having to use the docker-compose command to create and destroy stacks. Docker swarm also supports using multiple nodes, but I currently do not have a need for this.

docker swarm init will initialise the swarm with the current node as the manager.

Deploying Portainer

The next step is to deploy Portainer to manage our applications. You can download Portainer's default config from https://downloads.portainer.io/portainer-agent-stack.yml and modify it to your liking. Once you're happy, you can run:

docker stack deploy -c portainer-agent-stack.yml portainer

to download portainer and start up the stack. Portainer can then be configured through the web UI to set the login.

Deploying Traefik

Traefik is a great tool for self-hosting as it can automatically read docker labels, and configure the reverse proxy accordingly. This means not having to manually configure each service in multiple places. Below is the docker-compose file which I use.

# docker-compose.yaml
version: "3.2"
services:
  traefik:
    image: traefik:v2.5
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - certificate_data:/letsencrypt
      - config:/config
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - ACME_DNS_API_BASE=https://auth.acme-dns.io/ # use the acme-dns verification challenge for dns-01
      - ACME_DNS_STORAGE_PATH=/letsencrypt/acme-dns.json
      - LEGO_EXPERIMENTAL_CNAME_SUPPORT=true
    command:
      - "--api.dashboard=true"
      - "--providers.docker.swarmmode=true" # configure traefik to read from docker-swarm
      - "--providers.docker.network=frontend_net"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.file.directory=/config"
      - "--providers.file.watch=true"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
      - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
      - "--certificatesresolvers.default.acme.email=admin@example.com"
      - "--certificatesresolvers.default.acme.dnschallenge=true"
      - "--certificatesresolvers.default.acme.dnschallenge.provider=acme-dns"
      - "--certificatesresolvers.default.acme.keytype=EC256" # use EC certificates instead of RSA
      - "--certificatesresolvers.default.acme.storage=/letsencrypt/acme.json"
  whoami: # this is a placeholder service which is needed to configure the wildcard certificate
    image: "containous/whoami"
    restart: unless-stopped
    deploy:
      labels:
        - "traefik.enable=true"
        - "traefik.http.routers.traefik_whoami.rule=Host(`whoami.example.com`)" # expose whoami
        - "traefik.http.routers.traefik_whoami.tls.certresolver=default"
        - "traefik.http.routers.traefik_whoami.tls.domains[0].main=example.com" # configure the wildcard certificate
        - "traefik.http.routers.traefik_whoami.tls.domains[0].sans=*.example.com"
        - "traefik.http.services.traefik_whoami.loadbalancer.server.port=80"
        - "traefik.http.routers.traefik_whoami.middlewares=authelia@docker"
        - "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)" # expose the traefik dashboard
        - "traefik.http.routers.dashboard.tls.certresolver=default"
        - "traefik.http.routers.dashboard.service=api@internal"
        - "traefik.http.routers.dashboard.middlewares=authelia@docker" # put the traefik dashboard behind authelia
      resources:
        limits:
          cpus: "0.05"
          memory: 64M
networks:
  default:
    external:
      name: frontend_net # frontend network which serivces will have to join in order to be proxied

volumes:
  config:
  certificate_data: # volume for certs
    external:
      name: certificate_data

Deploying a new service

To deploy a new service, we only need to create a docker-compose file with the configuration for that particular service, and then add some Traefik configuration in order to make the service routable through the reverse proxy. Optionally, we can also add some middlewares to traefik such as forward-auth and URL rewriting.

# docker-compose.yaml
version: '3.2'

services:
  termpad:
    image: ghcr.io/lecafard/termpad # image name
    restart: unless-stopped
    volumes:
      - "data:/data" # volume mount
    deploy:
      resources: # resource limits are important as i'm hosting on a potato
        limits:
          cpus: "0.25"
          memory: 256M
      labels:
        - traefik.enable=true # tell traefik to automatically configure the load balancer
        - traefik.http.routers.paste.tls.certresolver=default
        - traefik.http.routers.paste.rule=Host(`${DOMAIN_NAME}`)
        - traefik.http.services.paste.loadbalancer.server.port=8000 # port which the service is listening on
        - traefik.http.routers.paste.middlewares=authelia@docker # pass auth to authelia using a custom middleware
        - traefik.http.routers.paste-public.tls.certresolver=default # allow public access to read
        - traefik.http.routers.paste-public.rule=(Host(`${DOMAIN_NAME}`) && Method(`GET`))
    environment: # configuration for the specific service
      - DOMAIN_NAME=${DOMAIN_NAME}
      - HTTPS=true
      - OUTPUT=/data
      - PORT=8000
      - DELETE_AFTER=0
volumes:
  data:

networks:
  default:
    external:
      name: frontend_net # network

Security

Services on the box are publically routable through their hostname, and can be accessed through a normal browser without a VPN. Authelia fronts all the non-public services including the portainer and traefik dashboard. I personally believe that this is a better model than using a VPN as it makes the applications more portable, and security is not compromised as we have an SSO proxy fronting everything.

Pros

Docker swarm and Portainer allows us to configure everything from a single control plane.

Not having to manually deal with port allocations. There are practically an unlimited amount of hostnames which can be used for a service.

Packing as many services as you can into a single host. This means more effective resource usage.

Cons

Deploying everything on a single box means that a hardware failure will take down everything. Since I am only hosting services for my personal usage on this VPS, I do not care too much if they go down. That being said, I am taking regular backups of the data for disaster recovery. You can also configure docker swarm with multiple nodes to make services more reliable.

Traefik is configured to fail-closed when Authelia is down, which means that it will render services which require authentication inaccessible. For those issues, I will have to ssh into the server to diagnose the issue and fix it from the console.

¯\_(ツ)_/¯