Skip to main content

Following this tutorial and don't have a VPS yet? Our Linux VPS start at €2.99/month with support included.

See our VPS

Deploy Docker Swarm on a VPS Cluster

You have one or more projects running under Docker Compose on a VPS, and you are starting to feel the limits: a server reboot takes everything down, updating an application means a few seconds of downtime, and you have no simple way to spread the load across a second machine.

Docker Swarm is the simplest answer to that problem. It is built into Docker, takes three commands to set up, and reuses your Compose files almost as they are. This guide builds a cluster of three YorkHost VPS (one manager, two workers) with an encrypted private network, a Traefik reverse proxy that handles certificates automatically, and everything you need to run it day to day.

Swarm or Kubernetes?​

The question comes up every time, so let's settle it up front.

Docker SwarmKubernetes (k3s, k8s)
Learning curveA few hoursSeveral weeks
Deployment filesDocker Compose (which you already know)Dedicated YAML manifests, Helm
Sweet spot2 to 20 nodes, a few dozen servicesHundreds of nodes and services
EcosystemModest, stableHuge, constantly moving
Autoscaling, operators, CRDsNoYes

For a team of 1 to 5 people who want resilience and zero-downtime deployments without dedicating a full-time position to it, Swarm is the right choice. If you are aiming for hundreds of microservices or a cloud-native tooling ecosystem, go straight to Kubernetes.

Target Architecture​

                    Internet
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ YorkHost Anti-DDoS β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β”‚ β”‚
β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
β”‚ swarm-1 β”‚ β”‚ swarm-2 β”‚ β”‚ swarm-3 β”‚
β”‚ manager β”‚ β”‚ worker β”‚ β”‚ worker β”‚
β”‚ Traefik β”‚ β”‚ apps β”‚ β”‚ apps β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
WireGuard private network 10.10.0.0/24
(Swarm traffic + encrypted overlay)
  • swarm-1 is the manager: it keeps the cluster state (Raft store) and schedules containers. It also hosts Traefik, the single HTTP/HTTPS entry point.
  • swarm-2 and swarm-3 are workers: they run the applications.
  • All three talk over a WireGuard tunnel: cluster management traffic never crosses the Internet in clear text.
Why three VPS with the same provider?

A Swarm cluster constantly exchanges heartbeats and overlay network traffic between its nodes. Three YorkHost Linux VPS in the same datacenter talk to each other with sub-millisecond latency over a 1 to 10 Gbps network, which makes the overlay almost transparent. Every node comes with Stormwall & Gcore Anti-DDoS protection included, plus 7-day rolling backups. For demanding workloads, the VPS Ultimate range on NVMe with 10 Gbps networking is particularly well suited to nodes hosting databases.

Prerequisites​

  • Three Debian 12 or Ubuntu 24.04 VPS, VPS-4 minimum for the workers, VPS-8 recommended for the manager if Traefik and applications run on it
  • Root or sudo access on each one, with SSH secured by key
  • A domain name whose DNS zone you control
  • The public IPs of the three VPS; in this guide: 203.0.113.11, 203.0.113.12, 203.0.113.13

Step 1: Prepare Each VPS​

On all three machines:

sudo apt update && sudo apt upgrade -y
sudo hostnamectl set-hostname swarm-1 # swarm-2, swarm-3 on the others
sudo timedatectl set-timezone Europe/Paris

Install Docker Engine from the official repository (the Debian package is too old). The full procedure is in Install Docker; in short:

sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker
docker --version

On Ubuntu, replace debian with ubuntu in both URLs.

Step 2: WireGuard Private Network Between Nodes​

Swarm encrypts its control plane (Raft, mutual TLS), but not container-to-container network traffic by default, and its management ports should never be reachable from the Internet anyway. A WireGuard tunnel solves both issues at once: all Swarm traffic flows over private 10.10.0.x addresses.

On all three machines:

sudo apt install -y wireguard
wg genkey | sudo tee /etc/wireguard/private.key | wg pubkey | sudo tee /etc/wireguard/public.key
sudo chmod 600 /etc/wireguard/private.key
cat /etc/wireguard/public.key

Write down the three public keys. Then create the configuration on swarm-1:

sudo nano /etc/wireguard/wg0.conf
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = <swarm-1 private key>

[Peer]
# swarm-2
PublicKey = <swarm-2 public key>
AllowedIPs = 10.10.0.2/32
Endpoint = 203.0.113.12:51820
PersistentKeepalive = 25

[Peer]
# swarm-3
PublicKey = <swarm-3 public key>
AllowedIPs = 10.10.0.3/32
Endpoint = 203.0.113.13:51820
PersistentKeepalive = 25

On swarm-2 (adapt for swarm-3 with 10.10.0.3 and the matching peers):

[Interface]
Address = 10.10.0.2/24
ListenPort = 51820
PrivateKey = <swarm-2 private key>

[Peer]
# swarm-1
PublicKey = <swarm-1 public key>
AllowedIPs = 10.10.0.1/32
Endpoint = 203.0.113.11:51820
PersistentKeepalive = 25

[Peer]
# swarm-3
PublicKey = <swarm-3 public key>
AllowedIPs = 10.10.0.3/32
Endpoint = 203.0.113.13:51820
PersistentKeepalive = 25

Bring the tunnel up everywhere and test it:

sudo chmod 600 /etc/wireguard/wg0.conf
sudo systemctl enable --now wg-quick@wg0
sudo wg show
ping -c 2 10.10.0.2 # from swarm-1

Step 3: Firewall​

The principle: the Internet only sees SSH, HTTP, HTTPS and WireGuard. Everything else goes through the tunnel. On each node:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # or your custom SSH port
sudo ufw allow 51820/udp # WireGuard

# Swarm ports, only from the private network
sudo ufw allow from 10.10.0.0/24 to any port 2377 proto tcp # cluster management
sudo ufw allow from 10.10.0.0/24 to any port 7946 proto tcp # node discovery
sudo ufw allow from 10.10.0.0/24 to any port 7946 proto udp
sudo ufw allow from 10.10.0.0/24 to any port 4789 proto udp # VXLAN overlay network

sudo ufw enable

On swarm-1 only (Traefik):

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
Docker and UFW

Docker manipulates iptables directly and bypasses UFW for ports published with -p or ports:. That is why, in this guide, only Traefik publishes ports (80 and 443); applications stay on the internal overlay network and are never exposed directly. If you publish an application port "just to test", be aware it will be open to the Internet even if UFW says otherwise.

Step 4: Initialize the Cluster​

On swarm-1, explicitly specifying the private address:

sudo docker swarm init --advertise-addr 10.10.0.1 --listen-addr 10.10.0.1:2377 --data-path-addr 10.10.0.1

The --data-path-addr flag matters: it forces container-to-container overlay traffic through WireGuard instead of the public IPs.

The command prints a join token for the workers. You can display it again at any time:

sudo docker swarm join-token worker

On swarm-2 and swarm-3:

sudo docker swarm join --token SWMTKN-1-xxxxxxxx --advertise-addr 10.10.0.2 --data-path-addr 10.10.0.2 10.10.0.1:2377
# (10.10.0.3 on swarm-3)

Check from the manager:

sudo docker node ls
ID          HOSTNAME   STATUS   AVAILABILITY   MANAGER STATUS   ENGINE VERSION
abc123 * swarm-1 Ready Active Leader 27.x
def456 swarm-2 Ready Active 27.x
ghi789 swarm-3 Ready Active 27.x
One manager or three?

The manager stores the cluster state in a Raft store. With a single manager, losing it freezes the cluster (containers keep running, but no more deployments or self-healing until it is restored). With three managers, the cluster tolerates the loss of one node. Never run two managers: two are no better than one (a majority is needed to decide), and four are no better than three.

To promote the workers to managers when you are ready: docker node promote swarm-2 swarm-3. On a small cluster, managers can also run applications; that is the default behavior.

Label the Nodes​

Labels let you constrain certain services to certain nodes (a database on the node with the NVMe disk, for instance):

sudo docker node update --label-add role=proxy swarm-1
sudo docker node update --label-add role=app swarm-2
sudo docker node update --label-add role=app swarm-3
sudo docker node update --label-add storage=nvme swarm-2

Step 5: The Overlay Network and Traefik​

Create an encrypted overlay network shared by Traefik and your applications. With WireGuard in place, overlay encryption is a second layer; it costs a little CPU, but keep it in case your nodes ever end up communicating outside the tunnel.

sudo docker network create --driver overlay --attachable --opt encrypted proxy

Traefik v3 discovers Swarm services and obtains Let's Encrypt certificates automatically. Create the stack file on the manager:

sudo mkdir -p /opt/swarm/traefik && cd /opt/swarm/traefik
sudo touch acme.json && sudo chmod 600 acme.json
sudo nano traefik.yml
services:
traefik:
image: traefik:v3.1
command:
- "--providers.swarm=true"
- "--providers.swarm.endpoint=unix:///var/run/docker.sock"
- "--providers.swarm.exposedByDefault=false"
- "--providers.swarm.network=proxy"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.le.acme.email=vous@example.com"
- "--certificatesresolvers.le.acme.storage=/acme.json"
- "--certificatesresolvers.le.acme.tlschallenge=true"
- "--api.dashboard=true"
- "--log.level=INFO"
ports:
- target: 80
published: 80
mode: host
- target: 443
published: 443
mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /opt/swarm/traefik/acme.json:/acme.json
networks:
- proxy
deploy:
mode: global
placement:
constraints:
- node.labels.role == proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.tls.certresolver=le"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.middlewares=dash-auth"
# Generate the hash with: htpasswd -nb admin 'motdepasse' | sed -e 's/\$/\$\$/g'
- "traefik.http.middlewares.dash-auth.basicauth.users=admin:$$apr1$$xxxxxxxx$$yyyyyyyyyyyyyyyyyyyyyy"
- "traefik.http.services.dashboard.loadbalancer.server.port=9999"

networks:
proxy:
external: true

Two details that matter:

  • mode: host on the ports means Traefik sees the visitor's real IP. With the default mode (ingress), every request appears to come from 10.0.0.x, which makes logs and rate limiting useless.
  • mode: global + the role == proxy constraint places exactly one Traefik instance on every node labeled proxy. For now that is swarm-1; labeling a second node (and adding another DNS record) is all it takes to get two entry points.

Deploy:

sudo docker stack deploy -c traefik.yml traefik
sudo docker service ls
sudo docker service logs -f traefik_traefik

Create the DNS record traefik.example.com β†’ 203.0.113.11 and open the dashboard over HTTPS after a minute or so, once Let's Encrypt has issued the certificate.

Step 6: Deploy a First Application​

Let's take a web application with its database. The file is plain Docker Compose, plus a deploy section per service:

sudo mkdir -p /opt/swarm/monapp && cd /opt/swarm/monapp
sudo nano monapp.yml
services:
web:
image: ghcr.io/votre-org/monapp:1.4.2
environment:
DATABASE_URL: postgres://monapp:motdepasse@db:5432/monapp
networks:
- proxy
- internal
deploy:
replicas: 3
placement:
constraints:
- node.labels.role == app
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
rollback_config:
parallelism: 1
order: start-first
restart_policy:
condition: on-failure
max_attempts: 3
resources:
limits:
cpus: "1.0"
memory: 512M
labels:
- "traefik.enable=true"
- "traefik.http.routers.monapp.rule=Host(`app.example.com`)"
- "traefik.http.routers.monapp.entrypoints=websecure"
- "traefik.http.routers.monapp.tls.certresolver=le"
- "traefik.http.services.monapp.loadbalancer.server.port=3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s

db:
image: postgres:17
environment:
POSTGRES_DB: monapp
POSTGRES_USER: monapp
POSTGRES_PASSWORD: motdepasse
volumes:
- db_data:/var/lib/postgresql/data
networks:
- internal
deploy:
replicas: 1
placement:
constraints:
- node.labels.storage == nvme # always on swarm-2, where its data lives

networks:
proxy:
external: true
internal:
driver: overlay
driver_opts:
encrypted: "true"

volumes:
db_data:
sudo docker stack deploy -c monapp.yml monapp
sudo docker stack services monapp
sudo docker service ps monapp_web

What happens: three web replicas are spread across swarm-2 and swarm-3, Traefik discovers them and load-balances between them, and the database runs on the only node that holds its volume. If a web container crashes or swarm-3 goes down, Swarm restarts the missing replicas elsewhere.

Zero-Downtime Update​

Change the image tag in the file, then run the same deploy command again:

sudo docker stack deploy -c monapp.yml monapp
sudo docker service ps monapp_web # watch the rolling replacement

Thanks to order: start-first and parallelism: 1, each new replica starts and passes its healthcheck before the old one is stopped. If the new version fails, failure_action: rollback restores the previous one automatically. To roll back manually:

sudo docker service rollback monapp_web

Adjust the Number of Replicas​

sudo docker service scale monapp_web=5

Step 7: Secrets and Configs​

The database password sitting in plain text in the previous example's YAML is fine for a test, not for production. Swarm has a secrets mechanism: encrypted in Raft and mounted into containers as files:

openssl rand -base64 32 | sudo docker secret create db_password -
sudo docker secret ls

In the stack:

services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password

secrets:
db_password:
external: true

Most official images accept a *_FILE variable. For your own applications, read /run/secrets/<name> at startup.

The same mechanism exists for non-sensitive configuration files (docker config create nginx_conf ./nginx.conf), which is very handy for distributing an Nginx configuration or a .env file to every node without a shared volume.

Step 8: The Volume Problem​

This is the single most important thing to understand about Swarm. A Docker volume is local to the node. If the database from the example gets rescheduled onto another node, it will start there with an empty volume. Hence the storage == nvme placement constraint that pins it to swarm-2.

Three approaches depending on your needs:

  1. Pin the service to a node (what this guide does). Simple and fast, but that node becomes a single point of failure for that service. Acceptable if you have good backups.
  2. Shared network storage: an NFS export from a storage VPS mounted on every node, using an nfs volume. Works well for files (uploads, media), poorly for databases.
  3. Application-level replication: PostgreSQL with streaming replication, MariaDB Galera, Redis Sentinel. Each instance has its own local volume, and the application handles redundancy itself. This is the robust solution, and it is real work.

Example NFS volume for shared files, with a YorkHost VPS Storage as the NFS server reachable over the WireGuard tunnel:

volumes:
uploads:
driver: local
driver_opts:
type: nfs
o: addr=10.10.0.10,rw,nfsvers=4,soft,timeo=30
device: ":/srv/nfs/uploads"
Don't reinvent database high availability

If your database is what matters most in your project, keep in mind that a truly fault-tolerant PostgreSQL cluster involves far more than a replica: leader election, split-brain protection, connection routing. We covered the topic in detail in PostgreSQL High Availability, and it is exactly what YorkHost Managed Infrastructure operates for you.

Step 9: Day-to-Day Operations​

Essential Commands​

ActionCommand
Node statusdocker node ls
Services and replicasdocker service ls
Where each container runsdocker service ps <service>
Logs of a service (all nodes)docker service logs -f <service>
Deploy / update a stackdocker stack deploy -c fichier.yml <stack>
Remove a stackdocker stack rm <stack>
Inspect a failed taskdocker service ps --no-trunc <service>

Node Maintenance​

Before rebooting a VPS for a kernel update, drain it: Swarm moves its containers elsewhere cleanly.

sudo docker node update --availability drain swarm-3
# ... update, reboot ...
sudo docker node update --availability active swarm-3

Back Up the Cluster​

The cluster state (services, secrets, configs, networks) lives in /var/lib/docker/swarm on the managers. Back it up regularly; it lets you rebuild a lost manager:

sudo systemctl stop docker
sudo tar -czf /var/backups/swarm-$(date +%F).tar.gz /var/lib/docker/swarm
sudo systemctl start docker

And above all, back up the volumes' data (database dumps, files) outside the cluster. The Automatic offsite backup with Restic or Borg guide applies directly.

Monitoring​

At a minimum, a docker service ls in a cron job that alerts when REPLICAS shows 2/3. To go further, a Prometheus + Grafana + cAdvisor stack, or Netdata, deployed in global mode covers every node; see Server monitoring. Portainer (Community Edition) also provides a full graphical interface for Swarm.

Cleanup​

Images from older versions pile up on each node. A weekly cron job on every VPS:

0 4 * * 0 docker system prune -af --filter "until=168h" > /dev/null 2>&1

Production Checklist​

  • Docker installed from the official repository on every node, same version
  • WireGuard up, ping working between private IPs
  • UFW: Swarm ports (2377, 7946, 4789) allowed only from 10.10.0.0/24
  • swarm init and join run with --advertise-addr and --data-path-addr on the private IPs
  • Traefik deployed in mode: host, Let's Encrypt certificates issued
  • No application publishes a port directly; everything goes through Traefik
  • Passwords stored in secrets, not in YAML
  • healthcheck and update_config with order: start-first on every web service
  • Stateful services pinned to a node or on suitable storage
  • Backups of /var/lib/docker/swarm and of volume data stored outside the cluster
  • Three managers if the cluster must survive the loss of a node

Troubleshooting​

A worker shows Down while the VPS is running​

The WireGuard tunnel dropped or UFW is blocking a port. Run sudo wg show on both nodes (the latest handshake field should be less than 2 minutes old), then sudo ufw status numbered. A systemctl restart wg-quick@wg0 followed by systemctl restart docker on the worker usually does the trick.

Containers on different nodes cannot see each other​

Port 4789/udp (VXLAN) is blocked, or --data-path-addr was not passed to the join. Check with docker node inspect swarm-2 --format '{{.Status.Addr}}' that the address is indeed the private one. If it is not, remove the node (docker swarm leave on it, docker node rm on the manager) and redo the join with the right parameters.

A task stays in Pending​

docker service ps --no-trunc <service> shows the reason: a placement constraint that cannot be satisfied (missing label), insufficient resources, or an image that cannot be pulled from that node (private registry without credentials: add --with-registry-auth to the stack deploy).

Traefik does not issue a certificate​

DNS does not point to swarm-1 yet, or port 80 is not reachable (Let's Encrypt must be able to reach the server). docker service logs traefik_traefik | grep -i acme gives the exact cause. Also check that acme.json is chmod 600, otherwise Traefik refuses to use it.

The manager is lost​

With a single manager, restore the /var/lib/docker/swarm backup on a new VPS with the same private IP, then run docker swarm init --force-new-cluster --advertise-addr 10.10.0.1. The workers reconnect on their own. This is the reason to move to three managers as soon as the project justifies it.

A cluster ready in minutes

YorkHost Linux VPS are delivered within minutes, with no commitment, all in the same datacenter with a fast internal network. You can therefore start with a cluster of three small nodes and add workers as the project grows, without reinstalling anything: one swarm join and the new node receives its first containers. If you would rather have a full physical machine to host your own VMs, the Proxmox and KVM on a dedicated server guide shows how to build your nodes yourself.