Skip to content

Docker Swarm is Dead. Long Live Compose.

Let’s get one thing straight. Someone, somewhere—a blog post, a well-meaning colleague, a YouTube tutorial—told you to run docker swarm init on your single-node VPS. They probably used phrases like “production-grade,” “future scalability,” or “access to advanced features like secrets.”

They were wrong.

What you’ve done is participate in a cargo cult. You’ve mimicked the rituals of large-scale distributed systems without understanding the underlying principles, and in doing so, you’ve saddled yourself with a solution that is actively worse than the simple, elegant alternative. You didn’t build a robust, scalable system. You built a fragile, over-engineered monument to bad advice.

For a single Docker host, Swarm mode is not just overkill; it’s a technical liability. It introduces unnecessary complexity, opaque state management, and fragile failure points for absolutely zero real-world benefit. Modern Docker Compose provides every feature you actually need—secrets, configs, healthchecks, resource limits—with none of the baggage. This isn’t a matter of preference. It’s about using the right tool for the job, and for a single server, Swarm is a sledgehammer for a thumbtack.

This guide will dissect the technical fallacies of single-node Swarm, show you the correct way to manage your applications with Docker Compose, and provide a clear, actionable plan to migrate away from the mess you’ve created. It’s time to stop playing orchestrator and start shipping software.


The Technical Debt of Unnecessary Orchestration

Section titled “The Technical Debt of Unnecessary Orchestration”

The “benefits” of single-node Swarm are a mirage. When you peel back the marketing layer, you find a collection of distributed systems components that are fundamentally pointless—and often dangerous—when run on a single machine.

The Raft Log: A Single Point of Failure You Gifted Yourself

Section titled “The Raft Log: A Single Point of Failure You Gifted Yourself”

At the heart of Docker Swarm is a Raft consensus log. This is a battle-hardened algorithm designed to keep the state of a distributed system consistent across multiple manager nodes. It’s a brilliant piece of engineering for a multi-node cluster, where it ensures that if one manager fails, the others can elect a new leader and continue operating seamlessly.

On your single-node “cluster,” there is no consensus. There are no other nodes. There’s just a log.

This log, located in /var/lib/docker/swarm/, becomes the single, authoritative, and terrifyingly fragile source of truth for your entire setup. All your service definitions, secrets, configs, and network configurations are stored in this binary, opaque directory structure. If that log becomes corrupted due to a disk error, a bad Docker upgrade, or an inopportune power loss, your entire “orchestrator” is toast. Congratulations, you’ve traded a stateless Docker host (where the source of truth is a version-controlled text file) for a stateful one with a fragile distributed systems component that has no peers to save it.

Terminal window
# The fragile heart of your single-node "cluster".
# If this directory gets corrupted, have fun rebuilding your services from memory.
# You've turned your server's state from a text file in git into this binary mess.
sudo ls -l /var/lib/docker/swarm/raft/
# total 8
# drwx------ 2 root root 4096 Apr 19 15:30 snap
# drwx------ 2 root root 4096 Apr 19 15:30 wal

Contrast this with Docker Compose. Where is the state? It’s in compose.yaml. A file you can read. A file you can check into Git. A file you can back up with cp. If your server melts, you spin up a new one, pull your repo, and run docker compose up. The source of truth is a version-controlled text file, not a finicky binary log that was never meant to be alone.

The Overlay Network: Needless Encapsulation for localhost

Section titled “The Overlay Network: Needless Encapsulation for localhost”

Another “advanced” feature you get with Swarm is the overlay network. It uses VXLAN encapsulation to create a virtual network that allows containers to communicate securely across different hosts. Again, a fantastic tool for a multi-node cluster.

On a single node, it’s a joke.

You are running containers on the same kernel. By using an overlay network, you are taking a network packet from container A, wrapping it in a VXLAN header, sending it through the kernel’s networking stack, only to have it unwrapped and delivered to container B, which is running on the same machine. This adds measurable latency and CPU overhead for literally zero benefit. You are paying a performance tax for a problem you do not have.

Furthermore, it complicates debugging. Simple tools like tcpdump or tshark now have to be told to decode VXLAN traffic, adding another layer of friction when you’re trying to figure out why your services can’t talk to each other.

Terminal window
# Inspecting a Swarm overlay network.
# Notice the "Driver": "overlay". That's your performance penalty right there.
docker network inspect my_swarm_network
# [
# \{
# "Name": "my_swarm_network",
# "Id": "a1b2c3d4e5f6...",
# "Driver": "overlay",
# "Scope": "swarm",
# ...
# \}
# ]
# Now inspect a Compose bridge network. Simpler. Faster. Correct.
docker network inspect my_compose_app_default
# [
# \{
# "Name": "my_compose_app_default",
# "Id": "f6e5d4c3b2a1...",
# "Driver": "bridge",
# "Scope": "local",
# ...
# \}
# ]

A standard Docker bridge network, which is what Compose creates by default, is a simple Linux bridge. It’s fast, efficient, kernel-native, and dead simple to reason about. It is the correct tool for single-host container networking.

The deploy Key Chimera: Rolling Updates for One Replica

Section titled “The deploy Key Chimera: Rolling Updates for One Replica”

“But docker stack deploy gives me rolling updates!”

Does it? Let’s analyze what a “rolling update” means for a service defined with replicas: 1. Swarm’s complex scheduler fires up, consults the Raft log, plans the deployment, and then meticulously… stops the old container and starts a new one.

That’s it. That’s the “advanced feature” you’ve added an entire orchestration engine for.

docker compose up -d does the exact same thing. It checks if a container exists for a service. If the image or configuration has changed, it stops the old one and starts a new one, intelligently reusing volumes and preserving network aliases. The end result is functionally identical, with often less downtime, and without the cognitive overhead of services, tasks, allocations, and the entire Swarm scheduler apparatus whirring away in the background for no reason.


It’s time to stop chasing complexity and embrace the power of simplicity. Modern Docker Compose is not a toy for local development. It’s a robust, feature-complete tool for defining and running multi-container applications on a single host.

This cannot be overstated. With Compose, your entire application stack is defined in a single, declarative YAML file. This file is your documentation, your deployment script, and your disaster recovery plan all in one.

  • It’s Reviewable: Changes are managed through pull requests.
  • It’s Versionable: You can git checkout any previous state of your infrastructure.
  • It’s Portable: You can run the same stack on your laptop or on a new production server.

There is no hidden state. What you see in compose.yaml is what you get on the host.

A Real-World compose.yaml That Replaces Swarm

Section titled “A Real-World compose.yaml That Replaces Swarm”

Let’s build a proper stack for a common use case: a Traefik reverse proxy handling SSL, a Ghost blog as our web application, and a MySQL database. This single file replaces an entire Swarm setup and is vastly easier to manage.

# compose.yaml - The sane way to run a multi-container app on one host.
version: '3.9'
services:
traefik:
image: "traefik:v3.0"
container_name: "traefik"
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
- "--certificatesresolvers.myresolver.acme.email=[your-email@example.com]"
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
- "8080:8080" # For the Traefik Dashboard
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "letsencrypt:/letsencrypt"
restart: unless-stopped
networks:
- web
webapp:
image: "ghost:5-alpine"
container_name: "my-ghost-blog"
environment:
database__client: mysql
database__connection__host: db
database__connection__user: ghost
database__connection__password_file: /run/secrets/db_password
database__connection__database: ghost_prod
url: https://[your-domain.com]
volumes:
- "webapp_content:/var/lib/ghost/content"
secrets:
- db_password
labels:
- "traefik.enable=true"
- "traefik.http.routers.ghost.rule=Host(`[your-domain.com]`)"
- "traefik.http.routers.ghost.entrypoints=websecure"
- "traefik.http.routers.ghost.tls.certresolver=myresolver"
- "traefik.http.services.ghost.loadbalancer.server.port=2368"
deploy:
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
resources:
limits:
cpus: '0.75'
memory: 512M
depends_on:
- db
networks:
- web
- default
db:
image: "mysql:8.0"
container_name: "my-db"
environment:
MYSQL_DATABASE: ghost_prod
MYSQL_USER: ghost
MYSQL_PASSWORD_FILE: /run/secrets/db_password
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
secrets:
- db_password
- db_root_password
volumes:
- "db_data:/var/lib/mysql"
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- default
volumes:
letsencrypt:
webapp_content:
db_data:
secrets:
db_password:
file: ./db_password.txt
db_root_password:
file: ./db_root_password.txt
networks:
web:
name: traefik_web_proxy

Let’s look at the compose.yaml above and dismantle the common reasons people claim they need Swarm.

  • Secrets & Configs: Look at the db and webapp services. They use the top-level secrets: key to mount sensitive data from files on the host directly into the container’s memory at /run/secrets/[secret-name]. This is native docker compose functionality. No Swarm required.
  • Healthchecks: The db service has a full healthcheck block. Docker will run mysqladmin ping inside the container periodically. If it fails, the container is marked as “unhealthy.” This is crucial for controlling startup order and restart logic. Again, native Compose.
  • Resource Limits & Restart Policies: The webapp service uses the deploy key. Yes, you can use a subset of the deploy key in Compose files without enabling Swarm mode. This allows you to specify restart_policy and resources (CPU/memory limits) just like you would in Swarm, but without any of the Swarm overhead.

Convinced? Good. Here is your escape plan. It’s a simple, four-step process to dismantle your unnecessary Swarm and replace it with a clean Compose setup.

Before you tear anything down, extract all the configuration from Swarm. You’ll need this to write your compose.yaml.

Terminal window
# Get the crucial details for each service. Pay close attention to:
# - Image tag
# - Environment variables
# - Mounts (volumes and binds)
# - Networks it's connected to
# - Published ports
docker service inspect --pretty my_web_app_service
docker service inspect --pretty my_database_service
# List all your secrets and configs. You'll need to recreate their contents on the host.
docker secret ls
docker config ls
# List your networks.
docker network ls

Go through each service and transcribe its configuration into a new compose.yaml file.

Using the information from Step 1 and the example above as a guide, create your compose.yaml file. Create the necessary secret files (like db_password.txt) on the host. This is just transcription; don’t overthink it.

This is the most satisfying step. It’s time to remove the complexity you never needed.

Terminal window
# First, gracefully remove your running applications (stacks).
# Replace 'my_stack_name' with the name of your stack.
docker stack rm my_stack_name
# Wait for everything to be removed.
# Now, leave the swarm. The --force is required because you are the last manager node.
# This is the command that sets you free.
docker swarm leave --force

Your Docker daemon is now back to a clean, simple, non-orchestrated state. The /var/lib/docker/swarm directory is gone. Breathe a sigh of relief.

The final, beautiful step.

Terminal window
# Navigate to the directory containing your new compose.yaml and secret files.
cd /path/to/your/app
# The beginning of a simpler, more robust life.
docker compose up -d

Your applications will start, managed by a simple, transparent tool that is perfectly suited for the job.


Your Job is to Maintain Systems, Not Orchestrators

Section titled “Your Job is to Maintain Systems, Not Orchestrators”

Let’s be clear. The goal of a systems administrator or a DevOps engineer is not to use the most complex, “cloud-native” technology available. The goal is to run services in a way that is simple, robust, reliable, and understandable.

For massive, multi-node clusters, a real orchestrator is essential. Go look at Kubernetes (or K3s for something lighter), HashiCorp Nomad, or even a properly implemented multi-node Docker Swarm. Those tools are designed to solve the hard problems of distributed computing: service discovery across hosts, multi-node networking, and distributed state management.

But for the vast majority of single-host deployments—the VPS running a website, the internal company tool, the homelab server—that complexity is not only unnecessary, it’s actively harmful. It adds failure points, obscures system state, and increases the cognitive load required for maintenance.

Stop the madness. Use the right tool for the job. Use Compose.