← Back to posts
comparison guide self-hosted · · 15 min read

Kubernetes vs Docker Swarm vs Nomad: Container Orchestration 2026

A comprehensive comparison of Kubernetes, Docker Swarm, and HashiCorp Nomad for container orchestration in 2026. Includes setup guides, Docker Compose examples, and a detailed feature comparison for self-hosters.

OS
Editorial Team

When your self-hosted setup grows beyond a single docker-compose.yml, you need a container orchestrator. The question is: which one? In 2026, the three leading open-source options are Kubernetes, Docker Swarm, and HashiCorp Nomad. Each takes a fundamentally different approach to the same problem — managing containers across multiple machines.

This guide breaks down all three, walks through real setup examples, and helps you pick the right orchestrator for your homelab or production environment.

Why Self-Host Your Own Container Orchestrator?

Running your own orchestration platform gives you complete control over your infrastructure. Instead of paying for managed Kubernetes on AWS, GCP, or Azure, you can run the same workloads on your own hardware — whether that’s a rack of servers, a cluster of Raspberry Pis, or a few repurposed desktops.

Key advantages of self-hosted orchestration:

  • Full data sovereignty — your containers, your data, your rules
  • No vendor lock-in — avoid cloud provider APIs and pricing models
  • Cost efficiency — after hardware, the software is free
  • Learning opportunity — deep understanding of distributed systems
  • Privacy — no telemetry or usage reporting to third parties
  • Custom scheduling — fine-tune placement policies for your hardware

Whether you’re running jellyfin for media, nextcloud for files, or dozens of microservices, a proper orchestrator handles health checks, rolling updates, service discovery, and load balancing automatically.


Docker Swarm: The Gentle Introduction

Docker Swarm is built right into Docker. If you already use docker compose, Swarm feels familiar — because it uses the same Compose file format with a few additions.

How It Works

Swarm turns a pool of Docker hosts into a single virtual Docker host. You designate one node as a manager (which orchestrates) and the rest as workers (which run containers). The manager uses the standard Docker API, so any tool that talks to Docker can talk to a Swarm cluster.

When to Choose Docker Swarm

  • You’re a solo operator or small team
  • You already know Docker Compose well
  • You need basic high availability and scaling
  • You want the simplest possible multi-host setup
  • Your workloads are straightforward web services and databases

Quick Start: 3-Node Swarm Cluster

Initialize the swarm on your manager node:

1
2
3
4
5
# On the manager node
docker swarm init --advertise-addr 192.168.1.10

# Copy the join command from the output, then run it on worker nodes:
# docker swarm join --token SWMTKN-1-... 192.168.1.10:2377

Verify the cluster:

1
2
3
4
5
docker node ls
# ID                            HOSTNAME      STATUS    AVAILABILITY   MANAGER STATUS
# abc123 *                      swarm-mgr     Ready     Active         Leader
# def456                        worker-01     Ready     Active
# ghi789                        worker-02     Ready     Active

Example: Deploying a Full Stack on Swarm

Here’s a production-ready docker-compose.yml for a web application with a database and reverse proxy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
version: "3.8"

services:
  traefik:
    image: traefik:v3.1
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yml:/etc/traefik/traefik.yml:ro
      - certs:/letsencrypt
    deploy:
      placement:
        constraints: [node.role == manager]
      restart_policy:
        condition: on-failure
    networks:
      - proxy-net

  webapp:
    image: registry.example.com/webapp:latest
    environment:
      - DATABASE_URL=postgres://appuser:${DB_PASS}@postgres:5432/appdb
    deploy:
      replicas: 3
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
      restart_policy:
        condition: on-failure
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    networks:
      - proxy-net
      - app-net
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.webapp.rule=Host(`app.example.com`)"
      - "traefik.http.routers.webapp.tls=true"

  postgres:
    image: postgres:17-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_DB: appdb
    deploy:
      placement:
        constraints: [node.role == manager]
      restart_policy:
        condition: on-failure
    secrets:
      - db_password
    networks:
      - app-net

  redis:
    image: redis:7-alpine
    deploy:
      replicas: 1
      restart_policy:
        condition: on-failure
    networks:
      - app-net

volumes:
  pgdata:
    driver: local
  certs:
    driver: local

secrets:
  db_password:
    external: true

networks:
  proxy-net:
    driver: overlay
  app-net:
    driver: overlay
    internal: true

Deploy with a single command:

1
2
3
docker stack deploy -c docker-compose.yml myapp
docker stack ps myapp    # Check running services
docker service ls         # List all services

Swarm Strengths and Weaknesses

AspecplexAssessment
Setup complexityVery low — docker swarm init and you’re done
Learning curveMinimal if you know Docker
High availabilityManager quorum (odd number of managers)
Rolling updatesBuilt-in with configurable rollout strategies
Service meshNone built-in; needs Traefik or similar
StorageBasic volume support; no advanced CSI
Resource limitsCPU and memory limits per service
EcosystemSmaller than Kubernetes but growing

Kubernetes: The Industry Standard

Kubernetes (K8s) is the dominant container orchestration platform. Created by Google, now maintained by the CNCF, it runs everything from homelabs to the largest cloud deployments in the world.

How It Works

Kubernetes uses a declarative model — you describe the desired state in YAML manifests, and the control plane continuously reconciles the actual state to match. Key concepts include:

  • Pods — the smallest deployable unit (one or more containers)
  • Deployments — manage replica sets and rolling updates
  • Services — stable networking endpoints for pods
  • ConfigMaps/Secrets — configuration and sensitive data
  • Ingress — HTTP/HTTPS routing to services
  • PersistentVolumes — abstracted storage management

When to Choose Kubernetes

  • You need advanced scheduling, autoscaling, or custom resource definitions
  • Your team has DevOps experience or is willing to learn
  • You want maximum ecosystem compatibility (Helm charts, operators, CNCF tools)
  • You plan to run complex, multi-tier applications
  • You want your skills to transfer to cloud-managed K8s

Quick Start: Lightweight K3s Cluster

For self-hosting, K3s (by Rancher/SUSE) is the best entry point. It’s a fully compliant Kubernetes distribution designed for resource-constrained environments — perfect for homelabs.

1
2
3
4
5
6
7
8
9
# On the server (control plane) node
curl -sfL https://get.k3s.io | sh -

# Get the node token
sudo cat /var/lib/rancher/k3s/server/node-token

# On each agent (worker) node
curl -sfL https://get.k3s.io | K3S_URL=https://192.168.1.10:6443 \
  K3S_TOKEN=<node-token> sh -

Verify the cluster:

1
2
3
4
5
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes
# NAME        STATUS   ROLES                  AGE   VERSION
# k3s-server  Ready    control-plane,master   5m    v1.32.3+k3s1
# k3s-agent1  Ready    <none>                 2m    v1.32.3+k3s1

Example: Deploying the Same Stack on Kubernetes

Here’s the equivalent deployment using Kubernetes manifests:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: myapp

---
# secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
  namespace: myapp
type: Opaque
stringData:
  POSTGRES_USER: appuser
  POSTGRES_PASSWORD: changeme-to-a-secure-password
  POSTGRES_DB: appdb
  DATABASE_URL: postgresql://appuser:changeme-to-a-secure-password@postgres:5432/appdb

---
# postgres-statefulset.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-pvc
  namespace: myapp
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 20Gi
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: myapp
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:17-alpine
          ports:
            - containerPort: 5432
          envFrom:
            - secretRef:
                name: db-credentials
          volumeMounts:
            - name: postgres-storage
              mountPath: /var/lib/postgresql/data
          resources:
            limits:
              cpu: "1"
              memory: 1Gi
            requests:
              cpu: "250m"
              memory: 256Mi
  volumeClaimTemplates:
    - metadata:
        name: postgres-storage
      spec:
        accessModes: [ReadWriteOnce]
        resources:
          requests:
            storage: 20Gi

---
# postgres-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: myapp
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: 5432

---
# webapp-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp
  namespace: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: webapp
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: webapp
    spec:
      containers:
        - name: webapp
          image: registry.example.com/webapp:latest
          ports:
            - containerPort: 8080
          envFrom:
            - secretRef:
                name: db-credentials
          resources:
            limits:
              cpu: "500m"
              memory: 512Mi
            requests:
              cpu: "100m"
              memory: 128Mi
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20

---
# webapp-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: webapp
  namespace: myapp
spec:
  selector:
    app: webapp
  ports:
    - port: 80
      targetPort: 8080
  type: ClusterIP

---
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: webapp-ingress
  namespace: myapp
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - app.example.com
      secretName: webapp-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: webapp
                port:
                  number: 80

Apply everything:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
kubectl apply -f namespace.yaml
kubectl apply -f secrets.yaml
kubectl apply -f postgres-statefulset.yaml
kubectl apply -f postgres-service.yaml
kubectl apply -f webapp-deployment.yaml
kubectl apply -f webapp-service.yaml
kubectl apply -f ingress.yaml

# Monitor rollout
kubectl rollout status deployment/webapp -n myapp
kubectl get pods -n myapp -w

Kubernetes Strengths and Weaknesses

AspectAssessment
Setup complexityHigh, but K3s brings it down significantly
Learning curveSteep — Pods, Deployments, Services, Ingress, PVs, RBAC
High availabilityMulti-master etcd clusters; battle-tested at scale
Rolling updatesSophisticated — blue/green, canary, progressive delivery
Service meshIstio, Linkerd, Cilium — industry-leading options
StorageFull CSI driver support; NFS, Ceph, Longhorn
Resource limitsGranular — CPU, memory, GPU, hugepages, ephemeral storage
EcosystemMassive — Helm, Operators, CNCF landscape

HashiCorp Nomad: The Pragmatic Challenger

Nomad takes a different philosophy: it’s a simple, flexible workload orchestrator that can run containers, VMs, Java apps, and raw binaries — all defined in a single HCL configuration format.

How It Works

Nomad uses a server/client architecture. Server nodes handle scheduling and cluster management (using Raft consensus), while client nodes run the actual workloads. Unlike Kubernetes, Nomad doesn’t try to manage every aspect of your infrastructure — it focuses purely on scheduling and running workloads.

When to Choose Nomad

  • You want to run mixed workloads (containers + VMs + binaries)
  • You value simplicity over feature breadth
  • You already use HashiCorp tools (Consul, Vault, Terraform)
  • You need a lightweight orchestrator for edge deployments
  • Your team finds Kubernetes too complex

Quick Start: Nomad Development Cluster

1
2
3
4
5
6
7
# Install Nomad (all nodes)
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install nomad

# Start a dev server (single node — for production, use proper server config)
nomad agent -dev -bind 0.0.0.0

For production, configure server and client nodes separately:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# server.hcl
data_dir  = "/opt/nomad/data"
bind_addr = "0.0.0.0"

server {
  enabled          = true
  bootstrap_expect = 3
}

consul {
  address = "127.0.0.1:8500"
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# client.hcl
data_dir  = "/opt/nomad/data"
bind_addr = "0.0.0.0"

client {
  enabled = true
  servers = ["192.168.1.10", "192.168.1.11", "192.168.1.12"]
}

plugin "docker" {
  config {
    allow_privileged = false
  }
}

Example: The Same Stack on Nomad

Nomad uses HCL (HashiCorp Configuration Language) job files. Here’s the equivalent deployment:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# myapp.nomad
job "myapp" {
  datacenters = ["dc1"]
  type        = "service"

  # ─── Reverse Proxy ───
  group "traefik" {
    count = 1

    constraint {
      attribute = "${node.role}"
      value     = "manager"
    }

    network {
      port "http"  { static = 80 }
      port "https" { static = 443 }
    }

    service {
      name = "traefik-dashboard"
      port = "8080"
    }

    task "traefik" {
      driver = "docker"

      config {
        image = "traefik:v3.1"
        ports = ["http", "https"]
        volumes = [
          "/var/run/docker.sock:/var/run/docker.sock",
          "local/traefik.yml:/etc/traefik/traefik.yml",
        ]
      }

      template {
        data = <<EOF
entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"
providers:
  docker:
    exposedByDefault: false
        EOF
        destination = "local/traefik.yml"
      }

      resources {
        cpu    = 250
        memory = 256
      }
    }
  }

  # ─── PostgreSQL ───
  group "postgres" {
    count = 1

    volume "pgdata" {
      type      = "host"
      read_only = false
      source    = "postgres-data"
    }

    constraint {
      attribute = "${node.role}"
      value     = "manager"
    }

    network {
      port "db" { to = 5432 }
    }

    service {
      name = "postgres"
      port = "db"
    }

    task "postgres" {
      driver = "docker"

      config {
        image = "postgres:17-alpine"
        ports = ["db"]
        volumes = ["pgdata:/var/lib/postgresql/data"]
      }

      env {
        POSTGRES_USER     = "appuser"
        POSTGRES_DB       = "appdb"
        POSTGRES_PASSWORD = "changeme-to-a-secure-password"
      }

      resources {
        cpu    = 500
        memory = 512
      }
    }
  }

  # ─── Web Application ───
  group "webapp" {
    count = 3

    network {
      port "http" { to = 8080 }
    }

    service {
      name = "webapp"
      port = "http"

      check {
        type     = "http"
        path     = "/healthz"
        interval = "10s"
        timeout  = "2s"
      }

      tags = [
        "traefik.enable=true",
        "traefik.http.routers.webapp.rule=Host(`app.example.com`)",
        "traefik.http.routers.webapp.tls=true",
      ]
    }

    restart {
      attempts = 3
      interval = "10m"
      delay    = "30s"
      mode     = "delay"
    }

    task "webapp" {
      driver = "docker"

      config {
        image = "registry.example.com/webapp:latest"
        ports = ["http"]
      }

      env {
        DATABASE_URL = "postgresql://appuser:changeme-to-a-secure-password@postgres.service.consul:5432/appdb"
      }

      resources {
        cpu    = 500
        memory = 512
      }
    }
  }
}

Deploy and manage:

1
2
3
4
5
6
nomad job run myapp.nomad          # Deploy
nomad job status myapp             # Check status
nomad job plan myapp.nomad         # Preview changes (like terraform plan)
nomad job stop myapp               # Stop the job
nomad alloc status                 # View allocations
nomad node status                  # View cluster nodes

Nomad Strengths and Weaknesses

AspectAssessment
Setup complexityLow to moderate — single binary, straightforward config
Learning curveModerate — HCL is easy to read, fewer concepts than K8s
High availabilityRaft consensus; 3 or 5 server nodes
Rolling updatesBlue/green deployments; canary support
Service meshConsul Connect integration
StorageHost volumes, CSI plugin support
Resource limitsCPU, memory, network, disk I/O
EcosystemHashiCorp ecosystem (Consul, Vault); smaller than K8s

Head-to-Head Comparison

Here’s how the three stack up across the dimensions that matter most:

FeatureDocker SwarmKubernetes (K3s)Nomad
Initial setup2 minutes15–30 minutes10–20 minutes
ArchitectureManager/workerControl plane/etcd/nodesServer/client
Workload typesContainers onlyContainers (+ CRDs)Containers, VMs, binaries, Java
Config formatDocker Compose YAMLKubernetes YAML/JSONHCL job files
ScalingManual or basic autoscaleHPA/VPA (autoscaling built-in)Manual or external scheduler
Service discoveryBuilt-in (DNS + VIP)CoreDNS + ServicesConsul integration
Load balancingBuilt-in (IPVS)kube-proxy + IngressConsul or external LB
Secrets managementBasic (Raft-encrypted)Secrets + external (Vault, SOPS)Vault integration
Rolling updatesYes, configurableYes, advanced strategiesYes, blue/green + canary
Self-healingYesYes (industry best)Yes
Dashboard/UIPortainer (external)Dashboard, Lens, OctantBuilt-in UI
Multi-clusterNo native supportFederation, Cluster APINomad federated clusters
Resource footprint~200MB per node~500MB–1GB per node~100MB per node
Binary sizeIncluded in Dockerk3s: ~70MB single binary~80MB single binary
Community sizeModerateLargestSmaller but active
Production maturityHighVery highHigh (HashiCorp, Cloudflare uses it)

Decision Framework

Choose Docker Swarm if:

  • You have 1–10 nodes and a small team (1–3 people)
  • You’re already comfortable with Docker Compose
  • Your applications are standard web services and databases
  • You want multi-host deployments with minimal learning
  • You don’t need advanced scheduling or custom resource types

Choose Kubernetes (K3s) if:

  • You have 5+ nodes and plan to scale further
  • You want access to the largest ecosystem of tools and operators
  • You need advanced features — HPA, VPA, admission controllers, webhooks
  • Your team has (or wants to build) DevOps expertise
  • You want skills that transfer to cloud environments
  • You plan to use GitOps (ArgoCD, Flux) for declarative management

Choose Nomad if:

  • You need to run mixed workloads — not just containers
  • You value operational simplicity over feature breadth
  • You’re already invested in the HashiCorp ecosystem
  • You need a lightweight orchestrator for edge or resource-constrained environments
  • You want the middle ground between Swarm’s simplicity and K8s’s power

The Verdict

For most self-hosters in 2026, K3s (lightweight Kubernetes) is the best long-term investment. The learning curve is real, but the ecosystem, community support, and skill portability make it worthwhile. The K3s distribution removes most of Kubernetes’ complexity — single binary, SQLite instead of etcd, built-in Traefik — while keeping full API compatibility.

That said, Docker Swarm remains the right choice if you want something that “just works” with your existing Docker knowledge. And Nomad is the underrated option that deserves more attention — especially if you run non-container workloads or already use Consul and Vault.

The good news? All three are open source, free to run, and can be tested on a single machine. Spin up a VM or container, try each one for a weekend, and see which workflow fits your brain.

Whichever you choose, you’ll have taken your self-hosted infrastructure to the next level — multi-node, self-healing, rolling-update-capable, and fully under your control.

Frequently Asked Questions (FAQ)

Which one should I choose in 2026?

The best choice depends on your specific requirements:

  • For beginners: Start with the simplest option that covers your core use case
  • For production: Choose the solution with the most active community and documentation
  • For teams: Look for collaboration features and user management
  • For privacy: Prefer fully open-source, self-hosted options with no telemetry

Refer to the comparison table above for detailed feature breakdowns.

Can I migrate between these tools?

Most tools support data import/export. Always:

  1. Backup your current data
  2. Test the migration on a staging environment
  3. Check official migration guides in the documentation

Are there free versions available?

All tools in this guide offer free, open-source editions. Some also provide paid plans with additional features, priority support, or managed hosting.

How do I get started?

  1. Review the comparison table to identify your requirements
  2. Visit the official documentation (links provided above)
  3. Start with a Docker Compose setup for easy testing
  4. Join the community forums for troubleshooting
Advertise here