Migrating Without Moving: Zero-Downtime PaaS Replacement on an Active Swarm Worker
When you decide to replace your self-hosted PaaS control plane, the conventional DevOps playbook sounds terrifying:
- Provision a completely new VPS.
- Manually recreate dozens of environment variables, databases, and Compose stacks.
- Schedule a dreaded 2 AM maintenance window.
- Cut DNS records, wait for global propagation, and pray that Traefikβs ACME certificates reissue before customers notice.
Tonight, we asked a harder, more interesting question:
What if you could deploy a next-generation, high-reliability PaaS control plane directly onto your existing production machineβan active Docker Swarm worker already running legacy workloadsβand migrate traffic live, with zero port conflicts, zero disk thrashing, and zero downtime?
Here is the architectural autopsy of how we engineered the migration from Dokploy to pikpik, how we solved the Swarm worker constraint, and how Cloudflare Tunnel makes in-flight control plane replacement effortless.
1. The Anatomy of the Legacy Problem
Popular open-source PaaS engines like Dokploy and Coolify are huge leaps forward for developer ergonomics, but under the hood, they inherit significant architectural debt:
- Heavyweight Distributed Footprint: Running a Node.js/tRPC runtime, Prisma ORM, a dedicated PostgreSQL database, and background workers requires
~1.5GBof idle RAM just for the management dashboard. - Brittle Shell String Execution: Container builds and deployments frequently rely on string-interpolated
exec.Command("sh", "-c", ...)commands on the host. One unescaped quote or corrupted image tag can leak zombie processes. - Disk-Thrashing Backups: Database dumps (
pg_dump,mongodump) are staged to local/tmpdisk files before being compressed and shipped to S3. On smaller $10 VPS nodes with large databases, an unexpected backup job can easily exhaust disk inodes and crash the host. - File-Driven Ingress Reloads: Routing changes require writing static configuration files or updating Traefik dynamic YAML files on disk, triggering process-level reload signals.
In contrast, pikpik was engineered from day one around Four Non-Negotiable Invariants:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Invariant 1 (Zero Shelling): 100% typed Docker SDK over /docker.sock β
β Invariant 2 (Unified Runtime): Single static ~14MB Go binary + SQLite β
β Invariant 3 (Dynamic Ingress): Caddy Admin REST API (sub-15ms mutations)β
β Invariant 4 (Pure Streaming): io.Pipe -> gzip -> S3 (RAM <32MB, 0 /tmp) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The challenge: how do you introduce pikpik into a machine that is already owned by Dokploy?
2. The Host Reality: An Active Swarm Worker
When inspecting the target machine, we uncovered two immediate constraints:
$ docker info --format 'Swarm: {{.Swarm.LocalNodeState}}, ControlPlane: {{.Swarm.ControlAvailable}}'
Swarm: active, ControlPlane: false
$ ss -tulpn | grep -E ':(80|443)\b'
tcp LISTEN 0 4096 0.0.0.0:80 0.0.0.0:*
tcp LISTEN 0 4096 0.0.0.0:443 0.0.0.0:*
- The Machine is a Swarm Worker: It does not have Docker Swarm Manager consensus authority. Any Swarm cluster mutations (
docker service create, scaling replicas) dispatched locally to/var/run/docker.sockwill be rejected by Docker Engine. - Ports 80 and 443 are Fully Bound: Dokployβs Traefik reverse proxy and Swarm ingress mesh are actively listening on
0.0.0.0:80and0.0.0.0:443.
If you try to spin up a second reverse proxy directly on the host, it will immediately crash with bind: address already in use.
3. The Coexistence Architecture: Cloudflare Tunnel as the Decoupler
Instead of wrestling over host ports 80 and 443, we decouple the ingress entirely using Cloudflare Tunnel (cloudflared):
βββββββββββββββββββββββββββ
β Public Client Traffic β
ββββββββββββββ¬βββββββββββββ
β HTTPS (443)
βΌ
βββββββββββββββββββββββββββ
β Cloudflare Edge Network β
ββββββββββββββ¬βββββββββββββ
β
Encrypted Outbound Tunnel (No Inbound Ports)
β
βΌ
ββββββββββββββββββββββββββββββββ Host Server ββββββββββββββββββββββββββββββββ
β β
β βββββββββββββββββββββββββββ β
β β cloudflared Daemon β β
β βββββββββββββ¬ββββββββββββββ΄βββββββββββββ β
β β β β
β HTTP (127.0.0.1:8080) HTTP (127.0.0.1:8088) β
β βΌ βΌ β
β βββββββββββββββββββββββββββ βββββββββββββββββββββββββββ β
β β pikpik Control Plane β β Caddy Dynamic Proxy β β
β β (Single Go Binary) β β (Dynamic REST Engine) β β
β βββββββββββββ¬ββββββββββββββ βββββββββββ¬ββββββββββββββββ β
β β β β
β β Typed Docker SDK β Reverse Proxy Dial β
β βΌ βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Local Docker Engine β β
β β - Standalone Apps & Compose Stacks β β
β β - Managed Databases (Postgres, Redis, Mongo) β β
β β - Coexisting Legacy Dokploy Containers β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β [ Legacy Dokploy / Traefik: Still running on 0.0.0.0:80 / 443 untouched ]β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Why This Coexistence is Flawless:
- Zero Port Collisions:
cloudflaredoperates via outbound QUIC/WebSocket connections. It does not bind to host ports.pikpiklistens on127.0.0.1:8080, and Caddy listens on an internal port like127.0.0.1:8088. - Standalone Docker Execution: While Docker Swarm cluster-wide service commands require a manager, standalone container lifecycles, Compose stacks, and managed databases execute directly against
/var/run/docker.sockwithout needing manager consensus.pikpikβsDockerStackManagerand application engines run at full speed. - Microscopic Footprint:
pikpikidles at~14β25MBof RAM. Running it side-by-side with Dokploy adds virtually zero load to the server.
4. The Data Plane Migration: Invariant 4 in Action
Migrating relational databases (PostgreSQL, MySQL, MongoDB) from Dokploy to pikpik is where Invariant 4 (Pure Streaming Pipelines) shines.
Instead of writing a 10GB .sql dump to disk and praying /tmp doesnβt fill up, we stream bytes directly through an OS pipe from the legacy Dokploy container into the new pikpik database container:
# Direct zero-disk PostgreSQL stream migration
docker exec -i dokploy_postgres pg_dump -U dokploy user_service_db \
| docker exec -i pikpik_db_postgres psql -U pikpik_user user_service_db
For persistent Docker volumes (uploaded assets, media, storage):
# Atomically copy data between Docker named volumes
docker run --rm \
-v dokploy_storage_volume:/from:ro \
-v pikpik_storage_volume:/to \
alpine sh -c "cp -a /from/. /to/"
Zero disk staging. Zero intermediate garbage files. Bounded memory consumption.
5. Ingress Translation: From Traefik Labels to Sub-15ms Caddy REST
In Dokploy, routing rules are declared as static container labels:
labels:
- "traefik.http.routers.api.rule=Host(`api.example.com`) && PathPrefix(`/v1`)"
- "traefik.http.middlewares.strip.stripprefix.prefixes=/v1"
In pikpik, the ingress engine translates these into structured ASTs and pushes them directly into Caddyβs in-memory dynamic Admin API (http://127.0.0.1:2019):
# pikpik CLI domain binding
pikpik-cli domain bind --app "app_api" --domain "api.example.com" --tls
Behind the scenes:
pkg/ingress/builder.gocompiles the route matchers, security headers (HSTS,X-Frame-Options), and upstream targets into a JSON payload.pkg/ingress/client.goexecutesPUT /id/route_app_api_api_example_comto Caddyβs memory.- The route goes live across all worker threads in
<15ms, without dropping a single active HTTP request or WebSocket pipe.
6. The Cutover: Instant & Reversible
Once the applications and databases are running and verified on pikpik via preview hostnames:
- Update the
cloudflaredingress rule:ingress: - hostname: api.example.com service: http://127.0.0.1:8088 # Caddy upstream - Reload
cloudflared(systemctl reload cloudflared). - Traffic immediately routes to
pikpik. - If an issue arises, flipping the tunnel config back to Dokploy takes less than two seconds.
- Once stable, stop the legacy Dokploy containers:
docker rm -f dokploy_app_container.
7. Lessons from the Frontier
Replacing a core infrastructure component does not require brute-force teardowns or high-stakes weekend migrations.
When you enforce strict architectural boundaries:
- A Single Unified Binary eliminates sprawling daemon dependencies.
- Pure Streaming Pipelines protect small nodes from disk exhaustion.
- Dynamic API Ingress eliminates clumsy config file reloads.
- Outbound Tunnels decouple ingress routing from physical host port bindings.
You donβt have to move servers to upgrade your cloud. You just have to build better primitives.