One VPS, Seven Services, One Repo — My Self-Hosting Setup
August 12, 2026
This post was translated into English by AI. Read the original →
In short: A single server at a German provider, 14 containers, seven services behind a reverse proxy. Mail, photos, analytics, VPN, monitoring, backups — all as Docker Compose, all in one Git repository, every decision documented.
This is the entry point to a series. The individual services have their own articles; this one is about what they have in common — and about what a setup like this actually costs, not in euros but in attention.
1. Why One Server Instead of Five Subscriptions
The trigger wasn't ideology but something I noticed while exporting from Google: I had no idea how many photos I owned. A service that silently swallows everything also takes away your sense of what it has swallowed.
Then there's the usual — prices that creep up, features that disappear, and the fact that a suspended account takes fifteen years of data with it.
The honest reason beyond that: I wanted to understand how these things work. A hosted service relieves you of exactly that understanding. Usually that's an advantage, and occasionally it's one too many.
What self-hosting is not: cheaper, more convenient, or inherently safer. It relocates trust and effort; it does not remove them. If you don't want that, you're better off with a good provider — and that isn't capitulation, it's a risk assessment.
2. What Runs
A VPS running Debian 13, 14 containers across seven services:
| Service | What for | Replaces |
|---|---|---|
| Traefik | Reverse proxy, TLS from Let's Encrypt | — |
| Stalwart | Mail, calendar, contacts | Gmail, Google Calendar |
| Immich | Photos and videos | Google Photos |
| Umami | Web analytics | Google Analytics |
| wg-easy | WireGuard VPN as an exit node | commercial VPN subscriptions |
| Uptime Kuma | Monitoring for every service | — |
| own app | Example: an application behind the proxy | — |
Plus restic as a shared backup layer — no containers, just scripts and a cron job.
Not all of it is equally demanding. Immich has run since March without a single incident. The mail server kept me busy for weeks.
3. One Edge Facing Outward
The most important decision in the whole setup, and it fits in a single sentence:
No service publishes its own ports. Everything goes through Traefik.
All containers sit on a shared Docker network and register with the proxy via Compose labels. The only things open to the outside are Traefik itself — 80, 443 and the mail ports — plus the WireGuard port.
Why this is more than tidiness: The attack surface is defined in exactly one place, namely Traefik's entrypoint list. Whatever isn't listed there cannot be reached from outside, regardless of what a container offers internally. My mail server runs more services internally than are reachable from outside. That's not a contradiction — that's the point.
Two exceptions, both deliberate:
WireGuard deliberately bypasses the proxy. It brings its own encryption, needs neither TLS termination nor client-IP forwarding — it sees the client IP natively. Forcing UDP through an HTTP proxy would be effort without payoff. Only the management interface runs through Traefik as usual.
The mail ports do run through Traefik, but as TCP passthrough rather than HTTP — with PROXY protocol, so the mail server sees the real sender IP. Without it, SPF checks, rate limiting and auto-banning would all act on the proxy's address instead of the sender's.
That this principle isn't self-evident became clear during a cleanup: two services were still publishing ports from an earlier phase — including a Postgres database that was consequently exposed to the internet. Both entries were leftovers serving no purpose.
And one detail many people miss: Docker writes its forwarding rules straight into iptables, bypassing ufw in the process. A firewall rule therefore does not protect you from an accidentally published container port. Only the binding in the Compose file does.
4. The Repo Is the Truth
The entire setup lives in a Git repository. Its folders map 1:1 to the directories on the server — /opt is itself a checkout of this repo.
The reason isn't convenience, it's drift detection. If the configuration on the server diverges from the documented one, git diff says so.
That exact situation was the trigger: a backup script on the server was at a different state than the documentation, because until then scripts had been maintained by copy-and-paste over SSH. It surfaced by chance during an audit — weeks later.
One thing matters here: credentials live outside this checkout, in a directory with different permissions. The backup repository password is the sole key to every backup; it has no business in any Git directory. The repo contains only example files with placeholders.
And the documentation sits next to the code, not somewhere else. Every folder has a README that describes not just what is configured but why — including the misdiagnoses that led to the configuration. Roughly 22,000 words in total, more than the articles in this series.
That sounds like a lot for a single server. But it's precisely the part you need six months later, when you want to know why a line looks the way it does.
5. Five Rules That Apply to Every Service
Over time, patterns emerged that hold for every service. They sound banal, and every single one traces back to a concrete mistake.
Pin versions, don't use latest. A casual docker compose pull should not roll in a major version with database migrations. For one service, latest even points to an older version than the version tag — if you don't know that, you unknowingly install the second-newest release.
One .env.example in the repo for every .env. That documents which variables exist without a single real value ever landing in Git.
No $ characters in generated passwords. Docker Compose interpolates them in .env files — a password silently turns into an undefined variable. Half an hour of debugging over one character.
Look inside the image before writing your own healthcheck. Most ship one. A hand-written one overrides it — and may then rely on a tool that doesn't exist in the container at all. That is exactly what happened.
Don't mark anything done that hasn't run manually at least once. This applies to every cron job. A script that only ever starts unattended at night gets its first real test at the worst possible moment.
6. Backups: The Guard Condition Is the Important Part
Four services back up into a shared encrypted repository at a provider in another country — geographically separated from the server, because a copy in the same data centre isn't a backup.
All four scripts follow the same structure. The decisive part isn't the backup command but the condition in front of it:
| Service | Checks before backing up |
|---|---|
| Photos | is there a database dump newer than 24 hours? |
| was the database dump actually produced, and is it non-empty? | |
| Files | did the preceding sync complete cleanly? |
| Monitoring | is the container running again after the stop, and is the database in the copy? |
Without those conditions you back up unusable data for weeks — an empty dump, a stale database, an aborted sync. A silent, half-broken backup is worse than none at all, because it lulls you into a false sense of safety.
Just how subtle this can get is shown by a bug I only found while writing this series: for the mail server, the condition checked whether the dump file was non-empty. But if the dump fails, the compression stage downstream still produces a 20-byte, formally valid file. The check waved it through, the backup ran, monitoring reported success. The guard meant to prevent silent failures was one itself.
On top of that, a dead man's switch: each script checks in with an external service after a successful run. If the check-in doesn't arrive, a notification does. The principle matters — it reports absence, not errors. That's why it still works when the server is gone entirely and no script is left running to report a failure.
And the part you must not skip: every backup path now has a restore that was actually performed. Testing surfaced a bug that would otherwise have shown up under time pressure in a real emergency. An untested backup is a guess.
7. Monitoring in Two Layers
For a long time the entire monitoring consisted of the backup pings. Those answer exactly one question — did a cron job run — and no sooner than 24 hours later.
Two incidents showed that this isn't enough. A DNS record was wrong for two weeks without anyone noticing. And during a lockout, the mail server's admin interface was completely unreachable while all backups happily kept reporting green.
Now there are two layers:
- Outside, the external dead man's switch: "is the machine alive at all".
- Inside, a monitoring service on the server: "which service exactly has a problem" — reachability, certificate lifetimes, and DNS records.
Why both are needed: a monitor on the machine being monitored cannot report that machine's failure. If the server dies, the monitor dies with it. That's not a weakness of the software, it's logic.
The trick that closes the gap without a third service: the inner monitor regularly checks in with the outer dead man's switch itself. If that stops, either the monitor or the whole server is gone — and the outer layer says so.
Two things matter here: the alerting channel must not depend on your own mail server — if that's the thing that's broken, the alert email never arrives. And the channel itself is deliberately not self-hosted, because it needs to work precisely when your own server doesn't.
8. What It Really Costs
Money: a VPS plan, block storage for the photos, a backup quota, a domain. Together, considerably less than the subscriptions it replaces. That's the uninteresting part.
Time: the build-out was spread over months. The mail server alone took several weeks — not continuously, but with a cutover, an observation phase and several rounds of debugging. Immich was one afternoon plus a second one for the backup.
Attention — and that's the real currency. Updates every few weeks. Spot-checking DNS records, because automation doesn't continuously reconcile what has already been published. Backups only count as backups if you repeat the restore occasionally.
The most honest sentence I can write about this setup: a single day of auditing surfaced an API key in the Git history, a certificate reload that had never run, a database exposed to the internet, and the broken backup guard described above. All of it had been running silently, some of it for months.
Self-hosting doesn't mean nothing breaks. It means you have to notice it yourself when something is broken. Backups and monitoring are therefore not an add-on to running services but a precondition for it — a service without both is data loss on a delay.
9. The Series
Five articles that together tell one story — first get the data back and organise it, then run it yourself, then secure it:
- My Johnny Decimal System for Files and Folders — the Google export and what to do with it. No server, but the beginning.
- Johnny Decimal Meets Obsidian — folders or metadata, and why the question is framed wrong.
- Setting Up Immich Photo Backup — replacing Google Photos, including a tested restore.
- Setting Up Stalwart Mail Server — the most demanding part. Running mail, calendar and contacts yourself.
- Setting Up a WireGuard VPN with wg-easy — your own exit node, and a bug that makes the tunnel look like it works.
If you only read one: the mail server article carries the most transferable material, because that's where the most went wrong.