Code Agency
5 min read

From Docker Swarm to Kubernetes: a migration you can do service by service

Swarm was the sensible choice in 2019; its ceiling is real in 2026. The strangler-pattern migration we run — same registry, same secrets model, one service at a time.

Swarm was the sensible choice in 2019: docker swarm init, a stack file, done. No control plane to babysit, no CRDs to learn, a routing mesh that just worked. Its ceiling is real in 2026. Scaling is a replica count, not a metric — there's no HPA equivalent watching CPU or queue depth. There's no admission control, so every image runs, signed or not. And the ecosystem moved on years ago: cert-manager, External Secrets Operator, Kyverno, every CNCF project we actually want targets Kubernetes and only Kubernetes. We still keep a couple of Swarm clusters alive for clients who aren't ready, but every new engagement is Kubernetes, and every existing Swarm client eventually asks how to get there without a weekend outage.

The answer isn't a cutover. It's a strangler-pattern migration — same registry, same secrets model, one service at a time, until Swarm is the thing running nothing.

Same registry, same secrets, before anything moves

Before a single service crosses over, the destination cluster needs to pull and run exactly what Swarm already pulls and runs. If Swarm nodes are pulling from a registry we control, Kubernetes points at the same one — no re-tagging, no parallel build pipeline. If it's a public registry, we mirror into our own so every image gets the same signing gate on the way in, the one we run on everything else we deploy: kaniko builds it, cosign signs it, Kyverno checks it at admission. A Swarm image with no signature is a Kubernetes admission failure on day one, which is exactly the incentive to fix the build pipeline before the migration, not after.

Secrets get the same treatment. docker service create --secret db_password mounts a value Swarm encrypts at rest in its Raft log — fine, but it doesn't rotate without a service update, and there's no audit trail beyond "someone ran the command." We replace it with the pipeline we run everywhere else: Infisical holds the value, External Secrets Operator materializes it as a native Secret:

the Swarm secret's replacement
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: app-db-credentials
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: infisical-backend
    kind: SecretStore
  target:
    name: app-db-credentials
  data:
    - secretKey: DB_PASSWORD
      remoteRef:
        key: DB_PASSWORD

Get this in place first, against the Swarm-era credentials, and every service migration afterward is a kubectl apply, not a secret-hunting exercise.

Mapping the primitives once, not per service

A docker-compose.yml stack file and a Kubernetes manifest set describe the same intent in different shapes, and the mapping is mechanical enough to do once and reuse:

SwarmKubernetes
deploy.replicasDeployment.spec.replicas (plus an HPA once metrics exist)
Overlay network + published portClusterIP Service + Ingress
docker service update --update-parallelismRollingUpdate strategy, maxUnavailable/maxSurge
Routing mesh (any node, any port)Ingress controller, TLS terminated the same way we do it for Odoo
--secret / --configExternalSecret / ConfigMap

The one primitive that doesn't map cleanly is the routing mesh — Swarm lets any node answer on the published port and forwards internally, while Kubernetes wants an Ingress in front of a Service. That's a real behavior change for whatever's calling the service, which is exactly why it's the first thing we settle per service, not something we discover mid-cutover.

One service at a time, traffic decides the order

We don't migrate whichever service is easiest — we migrate whichever service is safest to get wrong. Stateless, low-traffic, internal-only services go first: a cron runner, an internal admin tool, something with no external callers to notice a blip. Each one moves through the same four steps — deploy to Kubernetes behind its own Service, point the shared Ingress at the new backend instead of the Swarm routing mesh, watch it under real traffic for a few days, then decommission the Swarm service. Nothing about the calling code changes, because the DNS name and the API contract don't move — only what answers behind them does.

The services that stay on Swarm longest are the ones with the widest blast radius if we're wrong: the primary database, the payment path, anything with connections that don't tolerate a network hiccup during cutover. Those get the same rolling-update discipline we use everywhere elsemaxUnavailable: 0, a readiness probe that actually exercises the app, and a rollback that's a re-deploy of the last-known-good image, not a Swarm/Kubernetes hybrid nobody wants to debug at 2 AM.

GitOps from day one, not as a follow-up project

The mistake we've made once and won't make again: migrating a service to Kubernetes with a manual kubectl apply, meaning to "wire up GitOps later." Later doesn't come, and now there are two unmanaged deployment paths instead of one. Every service lands in ArgoCD the same day it lands in the cluster, following the GitOps loop we run for everything else — the manifest is the source of truth from the first commit, and Renovate starts opening version-bump PRs against it immediately instead of joining months in.

By the time the last Swarm service crosses over — usually the database, migrated with a maintenance window and a tested restore, not a live cutover — there's no "finish the migration" project left to schedule. Every service already has its manifest, its secrets reference, its Ingress rule and its place in the GitOps loop, because that's how it arrived. It's the migration path we run as part of every cloud hosting engagement that starts on Swarm: not a rewrite, a sequence of small, individually reversible steps that happens to end with Swarm turned off.

Want us to publish something specific?

Tell us what you'd like to read and we'll add it to our writing queue.

Get the next one in your inbox

New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.