Ongoing

Self-Hosting on One Hetzner VPS

Sole operator · 2026 · 10 min read

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.

Problem

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

  • 56
    Containers on one box
  • 8 GB
    RAM
  • 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.

ServiceReplacesMemoryWrite-up
DokployVercel, Heroku696 MB (+ 19 MB Postgres, 2 MB Redis)Moving this site off Vercel
TraefikAWS ALB51 MBReplacing AWS with Docker Swarm
UmamiUmami Cloud, Google Analytics113 MB (+ 19 MB Postgres)Self-hosting analytics behind Cloudflare Access
BugsinkSentry153 MB (+ 36 MB MySQL)mentioned in the DMARC post
dmarc-srgDmarcian, Postmark DMARC19 MB app, 3 MB MariaDB, 9 MB fetcherSelf-hosting DMARC reports and status pages
LibredeskIntercom, Zendesk42 MB app, 25 MB Postgres, 3 MB Redis, 3 MB nginx
cal.diyCal.com hosted, Calendly860 MB (+ 71 MB Postgres)Self-hosting cal.diy on an arm64 box
PlaneLinear, Jira697 MB across 12 containersSelf-hosting a Linear or Jira replacement
MetabaseLooker, Mode, Metabase Cloud1,216 MB (+ 68 MB Postgres)this one is new
UpptimeStatuspage, Better Stacknot on the box: GitHub Actions and PagesSelf-hosting DMARC reports and status pages
swarm-uptimeBetter Stack monitoringon the box, Hono API + BullMQ worker + Postgres + AstroReplacing 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

dmarc-srg has no Dockerfile in its repository, nothing on Docker Hub under liuch, and every community image I found had zero stars. Libredesk publishes an image but not the fork this box runs. cal.diy carries a Docker Hub pull-count badge in its README, and the repository behind it returns a count of zero tags.

Each one gets built somewhere different, for a reason.

dmarc-srg is built on the box from the pinned v2.3 tarball, because it is a 520 MB PHP image and rebuilding it is rare. The Libredesk fork is built on an M3 Max and streamed in, which is fine for a Go binary:

docker save libredesk-fork:agentreply | gzip -1 \
  | ssh -p 2222 dan@host 'gunzip | sudo docker load'

Both of those compose files pin pull_policy: never, because the tag exists only on the box and Docker would otherwise try Docker Hub and fail the deploy.

cal.diy is 8 GB and that approach does not survive it. Streaming it over domestic upstream moved 673 MB of the tar in eleven minutes, so it builds on a GitHub ubuntu-24.04-arm runner instead and the box pulls from GHCR at 442 MB/s.

The JVM does not read mem_limit

Metabase is the newest row and the largest single container, and getting it to start took two corrections that a Node or Go service would not have needed.

The heap first. A JVM sizes its default heap from what the machine reports, not from the cgroup the container runs in, so it took about a quarter of 7.5 GB and ignored the limit next to it. -Xmx768m fixes that. Then it crash-looped 27 times, exit 139:

fatal error: OutOfMemory encountered: Metaspace
Aborting due to java.lang.OutOfMemoryError: Metaspace

Metaspace, not heap. Metabase is Clojure and Clojure emits a class per function, so a 256 MB metaspace cap is nowhere near enough. The working budget is 768 MB heap plus 512 MB metaspace plus 128 MB of code cache, inside a 1,900 MB container limit. It settles at 1,216 MB.

Reading two other stacks caused the second failure. Each Dokploy stack keeps its own compose network, so Metabase joins Umami’s and cal.diy’s rather than anything moving, and both of those alias a service as database. So did Metabase’s own app database, and with four networks attached the short name resolved to cal.diy’s Postgres. Metabase then timed out its connection pool against a database with no metabase user in it, which surfaced as a Traefik 404 because there was no healthy backend to route to. A unique network alias for its own Postgres was the fix.

It reads both sources now, over HTTPS through Traefik: 4 websites out of Umami, and the single smoke-test booking out of cal.diy, in 44 ms and 14 ms.

What else runs on it

The same install also serves this site, Lightning CV, counselling-supervisor.ai, ilo-lang.ai and its conversion API, two Go-playing demos backed by KataGo, six Three.js demos on their own subdomains, and a pair of live-search implementations built for a comparison post.

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.