Self-Hosting on One Hetzner VPS
One Hetzner CAX serving 33 hostnames behind Dokploy: analytics, error tracking, DMARC reporting, live chat, scheduling and a row of small apps, with measured memory for each.
Overview
A single arm64 Hetzner CAX (4 vCPU, 8 GB, Helsinki) carrying 56 containers behind Dokploy and Traefik. Each service on it replaces something I was paying for or hitting a free-tier cap on: Umami for analytics, Bugsink for error tracking, dmarc-srg for DMARC reports, Libredesk for live chat, cal.diy for scheduling. Every one was picked on measured resident memory rather than on features, because the box has a fixed 8 GB and no autoscaler.
Challenges
Managed SaaS for a personal estate of sites is priced per seat, per website or per event, and the free tiers run out on the count of things rather than the volume of traffic. Umami Cloud allows three websites. Statuspage charges per subscriber. Sentry charges per event. None of the usage is large; the caps are on shape, not size.
Constraints
- Fixed 8 GB of RAM and 4 arm64 vCPU. No autoscaling, so every addition has to be measured, not estimated
- arm64 only. An amd64 image loads and starts, then dies with exec format error
- Docker on its own 50 GB volume after a full root disk took the machine down once
- Nothing on the box backs itself up, so anything stateful is a deliberate risk
- Cloudflare Bot Fight Mode is on zone-wide, which breaks any hostname that needs non-browser requests
Approach
Dokploy provides the deployment UI and Traefik does routing and Let's Encrypt. Most services are raw docker-compose stacks with an explicit mem_limit on every container, so a leak becomes one failed container rather than a dead machine. Anything with no arm64 image, or no published image at all, gets built somewhere and delivered: on the box, streamed in over SSH, or built on a GitHub arm64 runner and pulled from GHCR, depending on how big it is.
Key Decisions
Pick on measured resident memory, not on feature lists
parsedmarc is the more capable of the two, but the documented path puts OpenSearch on the box for a few hundred XML files a month. dmarc-srg measures about 105 MB for viewer, database and ingestion together. The smaller tool won because the constraint is RAM.
Match the build location to the image size, rather than always building locally
Three of these have no usable published image, and the same answer does not fit all three. dmarc-srg is built on the box. The Libredesk fork is built on an M3 Max and streamed in over SSH, which is fine for a Go binary. cal.diy is 8 GB, and streaming it moved 673 MB of tar in eleven minutes, so it builds on a GitHub arm64 runner and the box pulls it from GHCR at 442 MB/s instead.
Keep the status page off the box it reports on
Upptime runs its checks in GitHub Actions and serves from GitHub Pages, so it keeps reporting when the machine is down. The uptime monitor I wrote myself runs on the box and does not have that property.
An explicit mem_limit on every container in a compose stack
The box has no headroom to absorb a leak. A container with a limit gets OOM-killed and restarted by Docker; a container without one takes sshd and the deployment UI with it. Dokploy's own service is the exception at 696 MB, since it is not mine to cap. Metabase is the largest thing I do cap, capped at 1,900 MB and sitting at 1,216 MB, and picking that number needed the JVM's own accounting rather than a guess.
Tech Stack
Hetzner CAX (arm64) Docker Dokploy Traefik Postgres Cloudflare
Result & Impact
- 56Containers on one box
- 8 GBRAM
- 1,216 MB (Metabase)Largest single service
Analytics, error tracking, DMARC reporting, live chat and scheduling all run on hardware that was already paid for, and the website and event caps that prompted each move are gone. The costs that remain are operational: nothing on the box is backed up, and a full Docker disk took the machine offline once before Docker was moved to its own volume.
Learnings
- Measure before assuming a shared database saves anything. I expected a second database instance to cost 300 to 400 MB and found the existing MySQL container using 36 MB, so sharing would have saved nothing and cost a redeploy of the error tracker.
- Build-time and run-time memory are different problems. cal.diy's Dockerfile sets a 6 GB Node heap to build; the image it produces serves pages in under 900 MB.
- NEXT_PUBLIC_ variables are inlined at build time, so a runtime override is a no-op unless the project ships a find-and-replace step. Umami's TRACKER_SCRIPT_NAME has this problem and cal.diy solves it with a placeholder swapped on container start.
- A status page hosted on the infrastructure it reports on tells you nothing on the day you need it.
- A JVM ignores mem_limit. Metabase sized its heap from the host's 7.5 GB until -Xmx was set, and then crash-looped 27 times on a 256 MB metaspace cap, because Clojure emits a class per function. Exit 139 and an OutOfMemoryError, not the OOM kill a cgroup limit would have produced.
- Attaching one container to several compose networks makes short service names ambiguous. Metabase joined two other stacks to query them and then could not reach its own Postgres, because another stack also aliased a service as database. A unique network alias fixes it; a container name couples you to a generated suffix.
One arm64 Hetzner CAX, 4 vCPU and 8 GB, in Helsinki. Around 56 containers behind Dokploy, with Traefik terminating TLS and Cloudflare in front. Everything below runs on that one machine unless the row says otherwise.
What replaced what
Resident memory is docker stats on the box, taken in one snapshot rather than averaged.
| Service | Replaces | Memory | Write-up |
|---|---|---|---|
| Dokploy | Vercel, Heroku | 696 MB (+ 19 MB Postgres, 2 MB Redis) | Moving this site off Vercel |
| Traefik | AWS ALB | 51 MB | Replacing AWS with Docker Swarm |
| Umami | Umami Cloud, Google Analytics | 113 MB (+ 19 MB Postgres) | Self-hosting analytics behind Cloudflare Access |
| Bugsink | Sentry | 153 MB (+ 36 MB MySQL) | mentioned in the DMARC post |
| dmarc-srg | Dmarcian, Postmark DMARC | 19 MB app, 3 MB MariaDB, 9 MB fetcher | Self-hosting DMARC reports and status pages |
| Libredesk | Intercom, Zendesk | 42 MB app, 25 MB Postgres, 3 MB Redis, 3 MB nginx | |
| cal.diy | Cal.com hosted, Calendly | 860 MB (+ 71 MB Postgres) | Self-hosting cal.diy on an arm64 box |
| Plane | Linear, Jira | 697 MB across 12 containers | Self-hosting a Linear or Jira replacement |
| Metabase | Looker, Mode, Metabase Cloud | 1,216 MB (+ 68 MB Postgres) | this one is new |
| Upptime | Statuspage, Better Stack | not on the box: GitHub Actions and Pages | Self-hosting DMARC reports and status pages |
swarm-uptime | Better Stack monitoring | on the box, Hono API + BullMQ worker + Postgres + Astro | Replacing AWS with Docker Swarm |
Three rows have no post yet. Bugsink went on quietly alongside the DMARC work and only appears there as a memory comparison. Libredesk took a fork rather than the upstream image, because agents needed to be able to answer a notification email from their mail client and have it land back in the customer’s widget. Metabase went on last and is the largest single container on the box.
Three images nobody publishes
Three of the services have no image I could pull. dmarc-srg has no Dockerfile in its repository and nothing on Docker Hub under liuch. The community images I found had zero stars. Libredesk publishes an image, but not for the fork this box runs. cal.diy’s README carries a Docker Hub badge, and the repository behind it has no tags.
dmarc-srg builds on the box from the pinned v2.3 tarball. It is a 520 MB PHP image and rarely changes. The Libredesk fork is a Go binary, so it builds on my Mac and streams in:
docker save libredesk-fork:agentreply | gzip -1 \
| ssh -p 2222 dan@host 'gunzip | sudo docker load'
Both compose files pin pull_policy: never. The tag exists only on the box, and without that line Docker asks Docker Hub for it and fails the deploy.
cal.diy is 8 GB. Streaming it the same way moved 673 MB in eleven minutes over domestic upstream, so it builds on a GitHub ubuntu-24.04-arm runner and the box pulls it from GHCR at 442 MB/s.
The JVM does not read mem_limit
Metabase is the largest container on the box and needed two fixes before it started.
A JVM sizes its default heap from the machine, not the cgroup, so Metabase took about a quarter of 7.5 GB and ignored the container limit. -Xmx768m fixed that. It then crash-looped 27 times with exit 139:
fatal error: OutOfMemory encountered: Metaspace
Aborting due to java.lang.OutOfMemoryError: Metaspace
Metabase is Clojure, and Clojure emits a class per function, so the 256 MB metaspace cap ran out. The working budget is 768 MB heap, 512 MB metaspace and 128 MB code cache inside a 1,900 MB container limit. It settles at 1,216 MB.
The second failure was DNS. Each Dokploy stack has its own compose network, and Metabase joins Umami’s and cal.diy’s to read their databases. Both alias a service as database, as did Metabase’s own app database, and with four networks attached the short name resolved to cal.diy’s Postgres. Metabase timed out its connection pool against a database with no metabase user, which showed up as a Traefik 404 because there was no healthy backend. A unique alias for its own Postgres fixed it.
It now reads 4 websites out of Umami and the one smoke-test booking out of cal.diy, over HTTPS through Traefik, in 44 ms and 14 ms.
Where the cost sits
The machine is a fixed monthly bill in the range a single seat of most of the products in that table would cost. The real price is elsewhere: Docker filled the root disk once and took sshd, journald and Dokploy down with it, which needed a Hetzner rescue boot to recover. Docker now lives on its own 50 GB volume with an hourly prune and a guard that hard-prunes past 80%, so a full Docker disk is a failed build rather than a lost machine.
The box is close to full now. Available memory is 2,624 MB of 7,729 MB, disk is at 50% of the 50 GB Docker volume, and /proc/pressure/memory reads avg60=1.44 where it read 0.00 at every window before Metabase went on. That is the first real signal that the next service needs either a bigger box or something removed.
Nothing on the box is backed up. Every stateful row in that table is a named volume with no snapshot behind it, and that is the next thing to fix.