keep this in mind, i am no devops or server management expert, but i know my way around to get the best out of VPS ...
for a couple of months, my VPS (virtual private server) grew the way most personal-but-serious servers do: one app at a time. by this month it was running 11 production stacks. six Django apps (some with Celery workers and Telegram bots), a handful of Next.js sites, a Strapi CMS (content management system), an Express/Next monorepo, and an AI agent sitting on 2.8 GB of local state it could not afford to lose. roughly 36 containers across 12 Docker Compose projects, all fronted by a host nginx with 16 virtual hosts and certbot handling TLS.
deploys were the problem. two apps had GitHub Actions that SSH'd in and ran git pull && docker compose up -d --build. the other nine were deployed by me remembering the right command in the right directory. no unified logs, no deploy notifications, no scheduled database backups. it worked, but it was one careless docker compose down away from a very bad afternoon.
so i moved everything to Dokploy, an open-source PaaS (platform as a service, think self-hosted Vercel or Heroku), with push-to-deploy, per-app scoping, and scheduled backups to object storage. the constraints: same VPS, zero data loss, no meaningful downtime.
coolify or dokploy?
before committing, i had the classic self-hosted PaaS debate: Coolify vs Dokploy. i asked X, and most of the replies went with Coolify. i went with Dokploy anyway. a poll tells you what the crowd runs; it doesn't know your server. everything below is how that choice played out.
the stack, before and after
| layer | before | after |
|---|---|---|
| apps | Django x6 (Celery, Channels, Django REST Framework), Next.js x5, Strapi 5, Express, an AI agent | unchanged |
| databases | Postgres 13/16/17 + Redis, one per stack | unchanged (dumped and restored into new stacks) |
| orchestration | hand-run Docker Compose | Dokploy v0.29 on single-node Docker Swarm |
| ingress | host nginx, 16 vhosts | Traefik v3 (file + docker providers) |
| TLS | certbot on nginx | imported certs, then Let's Encrypt via Traefik |
| deploys | SSH scripts and manual commands | GitHub Apps webhook push-to-deploy (~2 s) |
| backups | none scheduled | nightly pg dumps + weekly volume tars to Linode Object Storage, Telegram alerts |the core problem: everything was already plugged in
Dokploy's installer hard-exits if ports 80, 443, or 3000 are taken. on this box, nginx owned 80 and 443, and Grafana owned 3000. the usual advice is to use a fresh VPS and cut over by DNS. that was off the table; i wanted the data to stay local and the server bill to stay singular.
the plan that worked:
- retire the old Grafana/Prometheus stack to free port 3000. Dokploy ships monitoring anyway.
- patch the installer (3 lines) so Dokploy is born on alternate ports: comment out the 80/443 checks, add
TRAEFIK_PORT=8081andTRAEFIK_SSL_PORT=8443env vars to thedokployservice, and publish Traefik on 8081/8443. Dokploy's own traefik setup honors those env vars when it regenerates config, so nothing fights you later. - run both proxies in parallel. nginx keeps 80/443 and public TLS; Traefik lives behind it. each migrated app's vhost gets a three-line change:
proxy_pass https://127.0.0.1:8443;
proxy_ssl_server_name on;
proxy_ssl_name $host; # without this, SNI = "127.0.0.1" and Traefik
# serves its default cert to a confused nginx- migrate one app at a time, verify it through Traefik, flip the vhost with a zero-downtime
nginx -s reload, and keep the old containers stopped but not removed as a one-command rollback. - the final 80/443 handover becomes a boring port swap at the very end. by then it is pure plumbing, no app risk.
one detail made every cutover cert-warning-free: i imported all 14 certbot certificates into Dokploy's certificate store up front. Traefik serves store certificates by SNI (server name indication) matching, so every staged domain presented its real, valid cert from day one. one config edit mattered here. Traefik's generated entrypoint defaulted to tls: {certResolver: letsencrypt}, which would have made every staged domain attempt doomed ACME challenges, since ACME (the automatic certificate management environment protocol Let's Encrypt uses) verifies over port 80, and port 80 still belonged to nginx. changing it to a bare tls: {} kept TLS on and certificate issuance off until the handover.
insurance before anything else
before touching a single container: pg_dump -Fc of all eight Postgres databases, tars of every media and Redis volume, a full archive of the agent's 2.8 GB state directory, plus the nginx and letsencrypt configs. stored locally and pushed to a new Linode Object Storage bucket.
one gotcha worth sharing: recent AWS CLI versions enable request checksums that break multipart uploads to some S3-compatible providers with a cryptic NoneType is not iterable error. the fix:
export AWS_REQUEST_CHECKSUM_CALCULATION=when_required
export AWS_RESPONSE_CHECKSUM_VALIDATION=when_requiredmigration order: a risk ladder
lowest-risk first, so every lesson was learned on an app that could not lose data:
- a static-ish Next.js site (no database). this proved the whole chain: build, domain, SNI, vhost flip. zero downtime.
- an Express/Next app on Supabase (external database, nothing local to lose).
- Strapi + Next blog, the first real database migration.
- the Django fleet: CMSes, marketplaces, an F1 telemetry app with Celery workers and a Telegram bot.
- the payments apps second to last: a wallet API and the fintech app that depends on it, migrated as a pair so the inter-app URL never changed.
- the AI agent dead last: registry image, stateful bind mounts, and a hard rule that two instances must never poll Telegram at once. stop old, then start new, with the state directory left in place and mounted by absolute path.
the per-app runbook for anything with a database:
deploy new stack from repo -> restore last night's dump -> smoke test via Traefik
T0: stop old app containers (writes frozen; old DB stays up)
final pg_dump from old DB (seconds; these DBs are 8-18 MB)
re-copy media volumes (catches uploads since the first copy)
pg_restore --clean into new DB
restart new app -> smoke test -> flip nginx vhost -> watch logs
stop old DB container (kept for rollback)write-freeze windows ran 2 to 5 minutes per app, off-peak. the Telegram bot gap was about a minute. everything else: zero.
things that actually broke
the honest section. every one of these cost real debugging time:
- Next.js standalone binds to the container hostname's interface instead of
0.0.0.0. the app logs "Ready", Traefik gets connection refused, users get 502. fix:HOSTNAME: 0.0.0.0in the compose environment. - a "harmless" env-file regex ate my S3 config.
grep -E '^[A-Za-z_]+='silently drops any key containing a digit, likeLINODE_S3_ACCESS_KEY_ID. Strapi fell back to local uploads and crash-looped on a missing directory. the regex you want is^[A-Za-z_][A-Za-z0-9_]*=. - Swarm rollbacks can eat your env vars. a service that crash-loops during an update gets rolled back to its previous spec, which on a first deploy is a spec without environment variables. the container then crashes because of the missing env, which triggers another rollback. escape hatch:
docker service rm, then redeploy so the service is created with the right spec. - managed database hostnames get a random suffix. you create
myapp-db; the actual DNS name on the network ismyapp-db-9jkdv8. copy it from the panel, don't guess. uv sync --no-devremoved gunicorn because it lived in aproddependency group. the flag combo you want is--no-dev --group prod.- Dokploy always does a
docker pullfor image-sourced apps, so locally-built images fail with "pull access denied". a loopback-only registry (docker run -p 127.0.0.1:5000:5000 registry:2) solves it; Docker trusts 127.0.0.0/8 without TLS ceremony. - Cloudflare's Universal SSL only covers one subdomain level. a 4th-level name like
admin.app.example.comfails TLS at the Cloudflare edge while the origin is perfectly healthy. this had been broken for months; the migration just found it. grey-cloud the record or avoid 4th-level names. - trailing whitespace in a
.envvalue made one backup job try to dump a database named"haaafla ". trim your values.
push-to-deploy: GitHub Apps and their sharp edges
Dokploy's GitHub integration is a GitHub App per repo owner, so i needed four (personal plus three orgs). the manifest flow has traps:
- landing on
github.com/settings/apps/manifestlooks wrong but is the normal confirmation page for both personal and org apps. don't bail, click create. - creating an app in an org requires the Owner role. as a mere member, GitHub silently creates the app under your personal account instead. (my org owner turned out to be my own second GitHub account. archaeology.)
- creating the app and installing it are separate steps, and the install must carry Dokploy's
?state=gh_setup:<id>parameter or Dokploy never learns the installation ID. the state only binds on a fresh install, so if you used the wrong URL, uninstall and reinstall via the panel's own Install button.
once wired, the numbers are great: push to webhook to deployment start in about 2 seconds, builds from cache, Telegram notification on completion. nine of eleven apps now deploy on git push.
backups, finally done right
the end state, all landing in one Linode Object Storage bucket, all in Africa/Nairobi quiet hours, all with Telegram failure alerts:
| what | cadence | retention |
|---|---|---|
| 8 Postgres databases | nightly 02:00-03:10 | 14 |
| Dokploy's own DB + config | nightly 03:20 | 7 |
| 4 media volumes | weekly Sun 03:30-04:00 | 4 |
| agent state (985 MB tar) | weekly Sun 04:10 (host cron) | 6 |Dokploy v0.22+ can back up compose-embedded databases, so multi-service stacks keep their databases inside the compose file and still get scheduled dumps. i fired every job manually once and verified the object in the bucket before trusting the schedule. until you have watched a backup land, you are holding a wish.
security wins along the way
- the Dokploy panel spent the whole migration firewalled off the internet, reachable only by SSH tunnel, until it got a proper domain behind Cloudflare with auth. note that you need iptables rules on both
INPUTandDOCKER-USER; published Docker ports bypass ufw, a fact that surprises everyone exactly once. - the migration killed a hole i didn't know i had: one app's Vite dev server had been publicly exposed on port 5173 for months. its new production image bakes the built assets, and the port is gone.
- two orgs' repos now deploy via short-lived, narrowly-scoped GitHub App installations instead of a root SSH key that could push anywhere.
the scoreboard
- 11 stacks migrated in one working day, sequentially, on the serving VPS.
- zero data loss. every database final-dumped inside its freeze window, every media volume re-copied at T0, row counts verified.
- downtime: 0 for stateless apps, 2 to 5 minutes of write-freeze for database apps, about a minute for the bot.
- 46 vulnerabilities flagged by Dependabot on one repo the moment i started pushing to it. the server is tidy; the dependency trees are next week's problem.
lessons
- parallel-run beats big-bang, every time. the old stack answering traffic while the new one boots next to it turns a scary migration into a series of boring, reversible flips.
- buy your rollback before you need it. dumping everything cost 20 minutes and let every later step be aggressive.
- sequence by blast radius. the static site ate all the process bugs; the payments apps inherited a debugged pipeline.
- the final dump belongs inside the freeze window, and so does the media re-copy. anything copied "yesterday" is stale by cutover.
- verify with the transport you will use in production.
curl --resolve domain:8443:127.0.0.1through Traefik's SNI caught issues thatcurl localhost:portnever would.
a note on tooling: this entire migration (inventory, planning, the installer patch, every cutover, the backup wiring, and most of the debugging above) was executed by Claude Code driving Dokploy's tRPC API headlessly, with me making the judgment calls and clicking the GitHub buttons it legally couldn't. the runbook-with-rollback style it enforced is the reason the war-stories section is funny instead of tragic.
if your server looks like mine did (a pile of compose files and muscle memory), you can do this in place on the same VPS. all it takes is a rollback for every step.
Written and Authored by Chris, Edited and largely assisted by Claude, ChatGPT, Gemini, Kimi(not Antonelli)
Comments