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-on | What it does |
|---|---|
dns | CoreDNS. Without this, nothing resolves. |
ingress | Traefik v3.6.2 as a DaemonSet with hostPort 80/443. |
hostpath-storage | Persistent volumes on local disk. No CSI driver needed. |
registry | Docker registry on localhost:32000. Same port Dokploy used. |
cert-manager | Automatic Let’s Encrypt via a ClusterIssuer. |
helm3 | For 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:
- Set kube-proxy to iptables mode (above).
- 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:
- Scale Docker Traefik to 0:
docker service scale dokploy-traefik=0 - microk8s Traefik automatically picks up ports 80/443 (hostPort means the pod binds directly to the node’s network interfaces).
- 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:
| App | Image | Notes |
|---|---|---|
| This blog | sha-e2c07e4 | Deployed from a non-main branch |
| App A | sha-xxxxxxx | GitOps pilot |
| App B | sha-xxxxxxx | SQLite volume |
| App C | sha-xxxxxxx | Was 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