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 Swarm | Kubernetes (k3s, k8s) | |
|---|---|---|
| Learning curve | A few hours | Several weeks |
| Deployment files | Docker Compose (which you already know) | Dedicated YAML manifests, Helm |
| Sweet spot | 2 to 20 nodes, a few dozen services | Hundreds of nodes and services |
| Ecosystem | Modest, stable | Huge, constantly moving |
| Autoscaling, operators, CRDs | No | Yes |
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.
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 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
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: hoston the ports means Traefik sees the visitor's real IP. With the default mode (ingress), every request appears to come from10.0.0.x, which makes logs and rate limiting useless.mode: global+ therole == proxyconstraint places exactly one Traefik instance on every node labeledproxy. 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:
- 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.
- Shared network storage: an NFS export from a storage VPS mounted on every node, using an
nfsvolume. Works well for files (uploads, media), poorly for databases. - 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"
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β
| Action | Command |
|---|---|
| Node status | docker node ls |
| Services and replicas | docker service ls |
| Where each container runs | docker service ps <service> |
| Logs of a service (all nodes) | docker service logs -f <service> |
| Deploy / update a stack | docker stack deploy -c fichier.yml <stack> |
| Remove a stack | docker stack rm <stack> |
| Inspect a failed task | docker 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,
pingworking between private IPs - UFW: Swarm ports (2377, 7946, 4789) allowed only from
10.10.0.0/24 -
swarm initandjoinrun with--advertise-addrand--data-path-addron 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 -
healthcheckandupdate_configwithorder: start-firston every web service - Stateful services pinned to a node or on suitable storage
- Backups of
/var/lib/docker/swarmand 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.
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.