From Dokploy to Flux — the long way around

I have a VPS somewhere in Singapore. 6 cores, 11 GB RAM, sitting in a datacenter I don’t think about. For a while it ran Dokploy — the self-hosted PaaS that wraps Docker Swarm, Traefik, and a nice UI around “deploy my app.” It worked. You click a button, you get a container behind a reverse proxy with automatic TLS. What’s not to like?

Well. This is the story of why I tore it all down and rebuilt it on microk8s + Flux, and why I’m probably not the fool for doing it. Or maybe I am.

The Dokploy era

Dokploy is fine. It does what it says. Docker Swarm, a Traefik instance that holds ports 80 and 443, a built-in Postgres, a private registry on port 32000. Deploys were hooked up to GitHub webhooks — push to main, Dokploy pulls the image and redeploys. It had metrics, it had logs, it had a dashboard. Swarm restarted dead containers on its own. For a personal box, it covered the basics.

So why tear it down?

Honestly? Because Docker Swarm is too simple. It’s a thin orchestrator that manages containers and not much else. No custom resources, no operators, no declarative reconciliation beyond “keep N replicas alive.” I wanted real Kubernetes — the kind I use at work — because I wanted the full toolkit: CRDs, Helm charts, Flux, proper ingress with TLS automation, the whole declarative model where the cluster state lives in Git and a controller fights to keep it that way. Docker Swarm gives you a hammer. Kubernetes gives you a workshop. Whether I needed a workshop for four personal apps is a fair question. I didn’t. I wanted one.

Installing microk8s

microk8s is the Canonical-maintained single-node Kubernetes that runs as a snap. It’s the least painful way to get a real cluster on a single VPS — no kubeadm ceremony, no etcd to babysit, just snap install and go.

snap install microk8s --classic
microk8s enable dns ingress hostpath-storage registry cert-manager helm3

The add-ons that matter:

Add-onWhat it does
dnsCoreDNS. Without this, nothing resolves.
ingressTraefik v3.6.2 as a DaemonSet with hostPort 80/443.
hostpath-storagePersistent volumes on local disk. No CSI driver needed.
registryDocker registry on localhost:32000. Same port Dokploy used.
cert-managerAutomatic Let’s Encrypt via a ClusterIssuer.
helm3For installing charts (monitoring stack).

One important tweak: kube-proxy defaults to IPVS mode, which conflicts with Docker Swarm’s IPVS rules (more on that below). I forced it to iptables:

echo "--proxy-mode=iptables" >> /var/snap/microk8s/current/args/kube-proxy
microk8s stop && microk8s start

The IPVS war

Here’s the part that cost me a whole evening.

When you have Docker Swarm and microk8s running on the same machine — even during migration — both use the kernel’s IPVS (IP Virtual Server) tables for service routing. kube-proxy in IPVS mode writes its own IPVS rules that clobber Docker Swarm’s rules. Result: every Swarm service starts returning 404. Traefik is up, the containers are running, but the routing tables are forked.

There’s a second dimension to this conflict. Docker’s iptables FORWARD and DOCKER-USER chain rules interfere with Calico’s pod-to-pod routing. So when a Kubernetes pod tries to reach a port that Docker has also published (say, port 3000), the packets vanish into a black hole.

The fix was both/either:

  1. Set kube-proxy to iptables mode (above).
  2. Remove Docker entirely once migration is complete.

I did both. The order mattered.

The Traefik handoff

This part was surprisingly clean. The microk8s ingress add-on ships Traefik as a DaemonSet bound to hostPort 80 and 443. Dokploy’s Traefik was also bound to those ports. Obviously they can’t both hold them.

The handoff:

  1. Scale Docker Traefik to 0: docker service scale dokploy-traefik=0
  2. microk8s Traefik automatically picks up ports 80/443 (hostPort means the pod binds directly to the node’s network interfaces).
  3. Traffic now flows through the k8s ingress controller.

No MetalLB needed. Single-node, hostPort, done. This was the one thing that “just worked” and I was suspicious for about an hour before accepting it.

Flux: the GitOps part

Flux CD watches a Git repository and applies whatever it finds. The repo is a private Git repository — a plain directory of Kubernetes manifests organized as Kustomize overlays:

vps-1-k8s/
├── base/           # deployment + service + ingress manifests
│   ├── app-a.yaml
│   ├── app-b.yaml
│   ├── app-c.yaml
│   ├── this-blog.yaml
│   ├── ingress.yaml        # one Ingress, all domains
│   ├── cert-issuer.yaml
│   └── kustomization.yaml
├── clusters/       # Flux sync config (GitRepository + Kustomization)
├── infrastructure/ # monitoring stack (HelmReleases)
└── overlays/       # environment-specific patches

Flux polls the repo every few minutes. When it detects a new commit on main, it reconciles — applies the manifests, restarts pods if the image changed, reports status. If I delete a manifest, it removes the resource. The cluster state is the repo. There is no other source of truth.

The Flux install itself is bootstrapped — Flux’s own components are committed to the same repo under clusters/. So even Flux manages itself. The snake eats its own tail and that’s fine.

The CI/CD pipeline

This is where it gets good. The deploy flow for every app:

git push → GitHub Actions (self-hosted runner on vps-1)
         → docker build -t localhost:32000/<app>:sha-<commit>
         → docker push
         → docker tag :latest (for rollback convenience)
         → registry-retention.sh (keep only 5 tags)
         → sed the new SHA into vps-1-k8s/base/<app>.yaml
         → git commit + push to vps-1-k8s repo
         → Flux detects the change → deploys

The self-hosted runner is key. GitHub Actions runs directly on the VPS, so docker build and docker push talk to localhost:32000 — no network transit, no Colima, no remote registry config. Build to push takes seconds.

The runner never runs kubectl. It doesn’t have cluster credentials. It just builds an image and edits a YAML file. Flux is the only thing that touches the cluster. If the runner is compromised, the worst case is a bad image in the registry — Flux won’t deploy it until the manifest changes, and the manifest change is a git commit with full history.

The retention script (/usr/local/bin/registry-retention.sh) keeps 5 tags per image. Old images get pruned automatically on every deploy. The registry doesn’t grow unbounded.

Here’s the actual workflow — this one is for this blog itself (deployed from a non-main branch):

name: Deploy

on:
  push:
    branches: [deploy]

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: [self-hosted, vps-1]
    steps:
      - uses: actions/checkout@v4

      - name: Set image tag
        id: tag
        run: echo "tag=sha-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

      - name: Build image
        run: docker build -t localhost:32000/myapp:${{ steps.tag.outputs.tag }} .

      - name: Push to microk8s registry
        run: docker push localhost:32000/myapp:${{ steps.tag.outputs.tag }}

      - name: Tag latest
        run: docker tag localhost:32000/myapp:${{ steps.tag.outputs.tag }} localhost:32000/myapp:latest && docker push localhost:32000/myapp:latest

      - name: Prune old images (keep 5)
        run: /usr/local/bin/registry-retention.sh localhost:32000/myapp 5

      - name: Update manifest
        run: |
          cd /home/runner/vps-1-k8s
          git pull --rebase
          sed -i "s|localhost:32000/myapp:sha-[a-f0-9]*|localhost:32000/myapp:${{ steps.tag.outputs.tag }}|" base/myapp.yaml
          git -c user.name="github-actions" -c user.email="actions@github.com" commit -am "myapp: update image to ${{ steps.tag.outputs.tag }}"
          git push

Every app has an identical workflow — just different image names, manifest paths, and trigger branches. One app was the pilot, then I migrated the rest.

The monitoring stack (briefly)

Since I was already doing this, I threw in observability. Via Flux HelmReleases:

  • kube-prometheus-stack — Prometheus + Grafana + AlertManager
  • Loki — log aggregation (single-binary mode, caches disabled to save memory on an 11 GB box)
  • Promtail — ships container logs to Loki

Grafana has four dashboards: cluster overview, pod resource usage, Loki logs, and ingress traffic. I disabled the built-in default dashboards because they were designed for clusters 50x this size and just added noise.

Loki fought me for a while. The distributed chart defaults to running caches, a query-frontend, a compactor, and a canary — none of which make sense for a single-binary deployment on a 6-core VPS. Each one was a separate commit disabling something:

694e550 fix: disable Loki caches and canary
74b8212 fix: use replicas: 0 for Loki distributed components
b188f6b fix: remove chunksCache/resultsCache overrides

The lesson: Helm charts assume you’re running at scale. On a small box, half the work is figuring out what to turn off.

The last three

Which brings us to today. Four apps live on the cluster:

AppImageNotes
This blogsha-e2c07e4Deployed from a non-main branch
App Asha-xxxxxxxGitOps pilot
App Bsha-xxxxxxxSQLite volume
App Csha-xxxxxxxWas serverless, containerized for this

The pilot app was the first to get the full treatment — SHA-tagged images, CI workflow, Flux reconciliation. It worked so well I left the other three on :latest for two weeks while I dealt with life.

Today I migrated the remaining three. The pattern was identical for each: build the image, push to registry, write the workflow, update the manifest from :latest to sha-<commit>, push, let CI take over.

The one complication was App C. It was a Vercel serverless function — a single api/github-webhook.ts handler with no Dockerfile, no server, no runtime dependencies. To containerize it I wrote a thin Bun HTTP adapter (server.ts) that wraps the Vercel-style handler in a Bun.serve() call, plus a minimal Dockerfile. No changes to the actual handler logic. It just runs the same code in a different shape.

All four services returned 200 within a minute of the first CI run.

Was it worth it

Depends on what “worth it” means. Dokploy already gave me git-push deploys via webhooks, metrics, logs, and self-healing containers. If the goal was just “run my apps reliably,” Dokploy was already there.

But I didn’t do this because Dokploy was broken. I did it because I wanted Kubernetes — the declarative GitOps model where the entire cluster state is a Git repository and a controller fights to keep reality matching it. Where I can git revert a deploy, where every change is a commit with a diff, where adding a new app means writing a YAML file and letting Flux handle the rest. Docker Swarm can’t do that. Not because it’s bad, but because it’s a different category of tool.

The real gains over Dokploy:

  • GitOps as the source of truth. The cluster state is a repo. Every deploy is a commit. Rollback is git revert. No UI clicks, no API calls — just Git.
  • SHA-tagged images with auto-prune. Every deploy is sha-<commit>, not :latest. The registry keeps 5 tags and prunes the rest automatically.
  • The Kubernetes ecosystem. Operators, Helm charts, custom resources. Things Docker Swarm simply doesn’t have.

The cost: about two weekends of work, one ruined evening fighting IPVS, and the ongoing cognitive load of maintaining a Kubernetes cluster for four personal projects. The nuclear reactor boils a very nice egg now — I just can’t pretend I needed a nuclear reactor.

— the fool