I run a stack of self-hosted services on one Linux PC at home: a private GitHub, a private Notion, a private Dropbox, two photo libraries, a password manager, a calendar, monitoring, and a few small utilities. Every one serves a real Let's Encrypt certificate. None of them opens a public port. Everything is reached over a private Tailscale network.
Those last two facts look contradictory. You can resolve photos.example.com from anywhere and get a valid certificate, yet a port scan of my home address returns nothing. Both hold because of two design choices: Caddy proves domain ownership through the DNS-01 challenge instead of HTTP-01, and it binds only to the Tailscale interface. This post explains that front door first, then walks the rest of the stack: the network that keeps the services apart, each service and what it replaces, the operations that keep it alive, and what it costs.
Self-hosted services on a single home PC, expanding to a couple dozen Docker containers, all behind one Caddy reverse proxy, all reachable only over a Tailscale tailnet. Every service has real TLS. Not one has a public port.
The setup scripts and Compose files are real, but the repo is private while I
clean it up for a public release. Every domain and IP below is a placeholder
(example.com, 100.x.y.z) on purpose. When it's published the link will land
right here.
The front door
The concern with putting a password manager and a photo library on a box in your house is that it sounds reckless. Two choices, working together, make it sound. Everything else in this post rests on them, so they get the most room.
There are three concentric rings of trust here, each one trusting less than the ring inside it.
The outermost ring is the public internet, and it only ever sees DNS records. It can look up photos.example.com and get an answer, and that answer is a 100.x.y.z Tailscale address it has no route to. The lookup succeeds and the connection goes nowhere. The middle ring is Tailscale, a WireGuard mesh where every device is trusted because I added it by hand. It is the only path to anything. The innermost ring is the Docker bridges, where containers reach each other, and even there they are kept apart in a way I'll come to.
Why the certificate is real
The usual way to get an HTTPS certificate, the HTTP-01 challenge, has Let's Encrypt connect back to your server on port 80 and read a file you placed there. That callback needs something reachable on your public WAN, which is the one thing a private homelab won't have. HTTP-01 and "nothing exposed" pull in opposite directions, so this stack uses DNS-01.
Caddy is built from source with the Cloudflare DNS plugin (an xcaddy build, image caddy-cloudflare:local). It proves ownership by answering a question only the domain's owner can: it writes a temporary TXT record into Cloudflare through the API, Let's Encrypt reads that record from Cloudflare's nameservers, confirms control of the domain, and issues the certificate. The proof happens entirely on Cloudflare's infrastructure. Let's Encrypt never opens an inbound connection to my home network. Caddy reaches out to place the order and download the certificate, but nothing inbound ever has to open.
On first boot Cloudflare handles the challenge in parallel for every subdomain, and the certificates come back inside about ninety seconds. Renewal then runs the same way, automatically, starting thirty days before expiry. The DNS records are a one-time job: cf-dns.sh upserts one A record per subdomain pointing at the Tailscale IP (ttl 120, grey cloud), and since Tailscale IPs are stable there is no recurring DDNS to maintain.
Why the door is shut
The second choice is a port binding. Caddy binds to ${TAILSCALE_IP}:443, not 0.0.0.0:443. Port 80 is bound too, on the same Tailscale IP, only for HTTP-to-HTTPS redirects, and 443/udp is there for HTTP/3. Nothing binds to the WAN interface. Bind to 0.0.0.0 and you would be listening on every interface including the public one; bind to the Tailscale IP and there is nothing on the public side to expose.
This is defence in depth. Even a fat-fingered port-forward rule on the router would expose nothing, because nothing is listening on the WAN for the router to forward to. The same one-line check verifies it, the one from my RustDesk post:
ss -tlnp | grep ':443'
# every line should read 100.x.y.z, never 0.0.0.0So a stranger can resolve photos.example.com, get an IP, and reach nothing, while I get a real certificate from the couch. The binding keeps it private; the DNS proof keeps the certificate valid.
One Caddy, databases that can't see each other
Two structural choices keep the stack from being a loose pile of containers.
First, a single shared Caddy fronts everything, rather than an nginx per service. Every app joins one Docker bridge, caddy_net (declared external so it survives restarts), and Caddy resolves backends like gitlab and outline by container name through Docker's embedded DNS, then routes by subdomain. Because everything funnels through one proxy, a few things are configured once and apply everywhere: HTTP/3, gzip and zstd compression, and a shared snippet that sets HSTS and the usual security headers and strips the Server banner. A handful of per-service tweaks live alongside it, like the large upload limits the photo services need (4 GB for PhotoPrism, 50 GB for Immich) and a WebSocket route split for Vaultwarden's live sync. The cost of a shared front door is a single point of failure: if Caddy is down, everything is unreachable. In practice it has been the most boring, reliable container in the stack.
Second, each service's database lives on its own private bridge, walled off from caddy_net. Outline's Postgres and Redis sit on outline_internal; PhotoPrism's MariaDB on photoprism_internal; Immich's Postgres, Redis, and machine-learning containers on immich_internal. A database container joins only its internal bridge, so Caddy can't reach it and neither can any other service. Outline has no route to PhotoPrism's database even though both run on the same host. The isolation is in the network wiring, not in a set of passwords, so a compromised service still has no path to another service's data.
Two more things fall out of this. Startup order is fixed: Caddy comes up first because it creates caddy_net and fetches the certificates, then GitLab because it is the login provider for Outline, then everything else. And every container carries a mem_limit, so when something runs away, the OOM killer takes the offending container rather than reaching for Caddy or the dashboard at random.
The hardware
It's a normal desktop, not a server rack. The figures below are what the stack needs, not what a datacentre would want.
| Thing | Minimum | Comfortable |
|---|---|---|
| RAM | 12 GB | 16 to 32 GB |
| Disk | 100 GB free SSD | 500 GB NVMe for OS and databases, plus a cheap HDD for photo originals |
| OS | Ubuntu 22.04+ or Debian 12+ | Same (tested on Ubuntu 26.04) |
| Network | Any home connection, CGNAT is fine | Same, Tailscale handles the NAT traversal |
What it takes to run the whole stack
The disk split matters more than the raw size. Postgres backs GitLab, Outline, and Immich, and PhotoPrism runs on MariaDB; all of them live or die on small random writes, so their data directories belong on NVMe. Photo and video originals are the opposite, written once and rarely read, so they sit happily on a cheap spinning HDD. The rule is short: databases on NVMe, originals on whatever's cheap and big.
Three things to have ready before setup: a domain pointed at Cloudflare DNS, a Cloudflare API token scoped to DNS edit on that one zone, and Tailscale installed on the PC and on every device you'll connect from. That last requirement is the real constraint of the design, covered at the end. Getting the prerequisites ready takes about thirty minutes, mostly Cloudflare and Tailscale account work plus DNS propagation. The setup run itself is quicker, with GitLab's three to ten minute first boot the longest single wait.
The services
Each service maps to something I used to rent. I'll lead with the heavy one.
GitLab
GitLab is the heavy service, and the one everyone notices. It idles at 4 to 8 GB, on par with all the other services combined, which sit at roughly 6 GB together, and it overtakes the rest of the stack as it climbs toward the top of that range. It's a private GitHub: repositories, CI/CD, and a container registry, packed into one Omnibus container that bundles Postgres, Redis, nginx, Puma, Sidekiq, Gitaly, and the registry.
The weight buys a second job. GitLab is also the OpenID Connect identity provider for Outline, so the wiki logs in through GitLab instead of running its own account system. Setup provisions that without anyone registering an OAuth app by hand: a gitlab-rails runner creates the application inside GitLab with the openid profile email read_user scopes, so single sign-on works on first boot.
Two things to expect. First boot is slow, three to ten minutes of Chef recipes (the healthcheck allows a 300-second start period), so an early 502 is normal rather than a failure. And its Postgres is a small-random-write hot path, so it wants NVMe.
The picker is what makes its RAM livable. When I'm not coding I untick GitLab and get the gigabytes back:
./homelab.sh pickThe picker projects total RAM for the resulting set before I confirm, and refuses to walk me into an out-of-memory kill without a second confirmation.
Outline
Outline replaces Notion: a real-time collaborative wiki with markdown, a fast editor, and full-text search over Postgres. It idles at about 500 MB.
It has no local username and password. Login depends entirely on GitLab's OIDC endpoints, which is the flip side of that convenience: if GitLab is down, Outline can't open. For my usage that's fine, since I rarely run one without the other, but it's a real dependency worth knowing before you lean on it.
OCIS
OCIS is Dropbox, replaced: ownCloud Infinite Scale, with the same desktop and mobile sync clients, versioning, and shares. It idles at about 500 MB. Under the hood it's a single Go binary that bundles its internal microservices (proxy, identity provider, storage, search, WebDAV) into one process, with no external database to babysit.
The one trap: the container runs as UID 1000. Restore its data with a plain cp that drops ownership and you'll get permission-denied until you chown -R 1000:1000 it back. Setup sets that ownership to UID 1000 for you; it drifts back only if a restore skips the chown or you recreate the directories by hand.
PhotoPrism and Immich
I run both photo libraries on purpose, for jobs that don't overlap.
Immich is the phone-facing one: native mobile apps, automatic backup, and semantic search. Type "dog on beach" and it finds the dog on the beach, because it stores CLIP embeddings in a pgvecto.rs vector database (the pinned tensorchord/pgvecto-rs:pg14-v0.2.0 image) and searches by meaning rather than EXIF tags. It's the iCloud Photos replacement.
PhotoPrism is the long-term archive: conservative, stable, and happy pointing at a read-only mount of originals. It does face recognition and RAW processing on the CPU with TensorFlow. It's the organized shoebox in the attic.
The numbers matter more here than anywhere. PhotoPrism idles around 1.5 to 2 GB, Immich around 1.5 to 2.5 GB, but both spike well above that during indexing. PhotoPrism pins every core for an hour or two on ten thousand new photos; Immich's machine-learning container is memory-hungry while it builds its index. Budget for the spike, not the idle. Their databases want NVMe (Immich's vector index especially); the actual originals are cold storage on a cheap HDD.
The whole stack idles around 11 to 12 GB. That's the calm number. PhotoPrism and Immich during indexing, and GitLab during CI builds, all climb into the mid-teens. Leave headroom, or use the picker to keep the heavy services off until you need them.
Vaultwarden
Vaultwarden replaces 1Password. It's Bitwarden-compatible, so every official Bitwarden browser extension and phone app points at it and works, with no custom client. The whole password manager, crypto and admin panel included, runs in about 50 MB of Rust, under 100 MB on disk. You could run it alone on a Raspberry Pi and never notice it. What keeps the admin token safe is the same wall that protects everything else: the service is never reachable from the public internet.
The quiet utilities
The small services that earn their keep without much fuss.
Radicale replaces iCloud calendar and contacts over CalDAV and CardDAV. It stores everything as plain .ics and .vcf files, so backups are an rsync, version control works natively, and there's no database dump to take. It idles at about 30 MB.
Headscale is the self-hosted Tailscale control plane, about 80 MB, plus a small admin UI. Running it keeps even my network's metadata on my own hardware. One precise point: Headscale is the control server you host, not a replacement for the Tailscale client apps. Devices still run Tailscale; they just check in with your server instead of the company's.
Monitoring is Grafana, Prometheus, node-exporter, and cAdvisor, about 800 MB together (cAdvisor is the hog). It's the same stack large companies run, giving per-container and host-level metrics, with the time-series database capped at 5 GB so it never sprawls across the disk. Dockge and Portainer round out the set as Docker UIs, about 130 MB and 120 MB respectively.
| Service | Replaces | Idle RAM |
|---|---|---|
| GitLab | GitHub | 4 to 8 GB |
| Immich | iCloud Photos | 1.5 to 2.5 GB |
| PhotoPrism | Google Photos archive | 1.5 to 2 GB |
| Monitoring | Datadog / New Relic | ~800 MB |
| Outline | Notion | ~500 MB |
| OCIS | Dropbox | ~500 MB |
| Dockge / Portainer | Docker UIs | ~130 / ~120 MB |
| Dashboard | (homemade) | ~100 MB |
| Headscale | (control plane) | ~80 MB |
| Caddy | reverse proxy + TLS | ~50 MB |
| Vaultwarden | 1Password | ~50 MB |
| Radicale | iCloud Calendar | ~30 MB |
The whole stack, by idle RAM
The dashboard
The one piece I built rather than installed. It's a Next.js 14 app at home.example.com that shows every service's health and the host's live stats, with a per-card action menu offering Start, Stop, and Restart on the services that allow it.
The data is server-side. The page renders on every request with no cache, reading metrics from the Prometheus HTTP API on the server: CPU, RAM, disk, uptime, load, temperature, network and disk I/O, and the top containers by usage. CPU, RAM, disk, and network each get a one-hour sparkline; the top containers show as a CPU and memory bar table. A JSON endpoint behind it revalidates on a ten-second window, and any Start or Stop revalidates the home-page route so the grid refreshes immediately rather than waiting on the window. The interactive parts, the theme toggle, live clock, search filter, per-card action menu, and the detail modals, are a handful of client components on top of that server-rendered core.
It can start and stop containers because it mounts the Docker socket read-write, which is effective root on the host. Three actions are exposed through dockerode (start, stop with a grace period, restart), and Caddy and the dashboard itself are deliberately not controllable, since stopping either would take the page down with it. That read-write socket is the reason this service only ever lives behind Tailscale; it's the last thing I'd want exposed, and the front-door design means it can't be.
The command surface
The day-to-day runs through one script. The verbs map to the lifecycle.
| Command | Does |
|---|---|
status [service] | health and URLs, whole stack or one service |
setup | first-time interactive wizard (.env, certs, services) |
pick | toggle services on or off, with a RAM projection first |
up / down [service] | start or stop, in dependency order |
restart [service] | restart all or one |
update [service] | pull latest images and restart (opt-in) |
logs | tail one service's logs (service name required) |
backup | run the built-in database dumpers |
dns | re-sync the Cloudflare A records to the Tailscale IP |
doctor | read-only health check |
abort | stop everything and drop caddy_net (data preserved) |
nuke [service] | destructive wipe of data and config |
homelab.sh subcommands
doctor is the one I reach for first when something's off. It's read-only and checks the Docker daemon, the Compose plugin, the .env keys, that caddy_net exists, and that every service script is present and executable, all without changing a thing. Most problems surface there before I open a single log.
pick is the RAM governor. It excludes Caddy and the dashboard (unticking those would brick the stack and the UI), pre-ticks whatever's running, then projects the total for the set you've chosen against total host RAM: under 80 percent is comfortable, 80 to 94 percent prints a warning and flags GitLab, Immich, and PhotoPrism as the heaviest services to untick, and 95 percent or above is flagged as dangerous and needs a second confirmation.
Operations
Recovery after a power cut
Everything comes back on its own. The containers carry restart: unless-stopped, Tailscale brings up the same static IP, caddy_net survives because it's declared external, and the certificates are already on disk so nothing re-issues. Most service containers also ship a liveness probe, so status can tell a healthy service from one that is up but wedged. A few, including Caddy and Portainer, carry none, so for those status shows only running or stopped. For surgical recovery there's abort, which stops everything and removes caddy_net while leaving data and .env intact, and a per-service nuke that wipes one service without disturbing the rest.
Backups
The rule is three copies: one original, one local, one offsite. ./homelab.sh backup runs the proper dumpers for the services that need them (GitLab's repo-and-DB backup, pg_dump for Outline and Immich, a MariaDB dump for PhotoPrism, and a SQLite snapshot for Vaultwarden), while the file-based services (OCIS, monitoring, Radicale, Headscale) are a straight tar of their data directories. The secrets to copy offline are the .env files and GitLab's irreplaceable gitlab-secrets.json, and the large media (PhotoPrism originals, Immich library) goes by rsync. The weekly drill is about five minutes and cron-able.
I learned the next part the expensive way. An untested backup isn't worth much, so once a year I restore the whole thing onto a fresh VM and confirm it comes back. Backups have a way of being broken in the exact moment you need them.
Moving to a new PC
Calmer than it has any right to be. The folder is self-contained: configuration layers from a shared .env into per-service .env files, and every bind mount is relative, so moving the directory moves the whole stack with it. Bring it down on the old host, rsync -aHAX the folder to the new one, sign into the same tailnet, update one line in .env with the new Tailscale IP, run ./homelab.sh dns to re-point the records, and bring it up. About thirty minutes plus the copy. The certificates still work without re-issuing, because they're tied to hostnames like git.example.com, not to the machine.
Two traps worth knowing
▶The Tailscale key-expiry trap
Tailscale auth keys expire after 180 days by default. On a home server that's a silent landmine: the key lapses, Tailscale re-authenticates and hands the machine a new IP, and suddenly every DNS record points at the wrong place and the stack goes dark. The fix is one toggle in the admin console, disable key expiry on the home server, set once. Do it before it bites, not after.
▶The NXDOMAIN negative-cache trap
Create a new subdomain and try it too eagerly, and a resolver may have already cached the fact that it didn't exist a moment ago. That negative answer can stick around for half an hour. The clean fix is a tailnet-wide DNS override pointing at 1.1.1.1, which bypasses the home router's cache entirely. The provisioning flow also writes the record before anything queries it, so in practice it rarely shows up.
Going further: snapshots
There's an optional layer I haven't had to lean on much. The stack can run inside a Proxmox VM provisioned by cloud-init, which adds one-button snapshot and rollback before a risky change. It's a safety net beyond backups if you want one, and skippable if you don't.
What this protects, and what it doesn't
I'd rather be plain about the edges than oversell the middle.
What the model gives you
Zero public ports, so there's nothing at the network layer to scan or attack. A Cloudflare token scoped to DNS-edit on one zone only. Per-service passwords. Database bridges that contain a compromise to a single service.
What it doesn't defend
Theft of your Tailscale account, so put 2FA on the login behind it. Someone with shell access to the host. Physical theft of the disk, which LUKS would answer. And data loss if you never back up. The architecture closes the network; the rest is ordinary hygiene.
A short hardening checklist sits under all of it: UFW set to deny inbound and allow outbound and SSH; password SSH disabled in favour of keys; unattended security upgrades on. None of it is exotic. It's the care any always-on machine deserves, plus the one thing the architecture buys for free: nothing answers on the WAN.
The Cloudflare token is the dependency people get wrong, so it deserves a specific warning. Scope it to DNS-edit on your single zone, leave the IP filter blank (a home IP moves, and a filter would eventually block cert renewal and break HTTPS everywhere), and don't set a forced expiry you'll forget to renew. A leaked token can't touch any other domain and is revocable in one click, but if Caddy can't reach the API for thirty days the certificates lapse, so point the ACME email at an inbox you read.
What it costs
The recurring cost is small: a domain registration, an optional offsite backup if you want one, and the electricity an always-on PC draws. Everything else runs on free tiers. What I traded for that is labor, since I'm now my own sysadmin, backup service, and support desk. There are real things I gave up: I can't text a share link to someone who isn't on my tailnet, since every device that reaches the stack needs Tailscale, and if the house burns down, the offsite backup is the whole story.
What I got back is that my notes, photos, passwords, files, and calendar all live on hardware I can put my hand on, I know exactly what runs and why, and no renewal notice arrives in the background again. When the repo is cleaned up and public, the link will be at the top of this post. Until then, that's the picture: a stack of services on one always-on box, on a private network, with a real certificate only I can reach.
References
- [1]Let's Encrypt. "Challenge Types: DNS-01 challenge." [Online]. Available: https://letsencrypt.org/docs/challenge-types/
- [2]Caddy. "Automatic HTTPS and the ACME DNS challenge." [Online]. Available: https://caddyserver.com/docs/automatic-https
- [3]Tailscale Inc. "How Tailscale works." [Online]. Available: https://tailscale.com/blog/how-tailscale-works