I turned an old laptop into my own private cloud

Nov 04, 2026

A few weeks ago my home server was a box running some Docker containers, with ports I wasn't sure about and no backups. Today it serves my photos, blocks ads on every device I own, runs workflow automation with an AI assistant, and deploys itself when I merge a pull request. Every app has real HTTPS and one shared login, and nothing is exposed to the internet that I didn't put there on purpose.

The hardware is a 2016 HP laptop: an Intel i3, 8 GB of RAM and a spinning hard drive. The whole thing uses about 4 GB of RAM, and the laptop battery doubles as a free UPS.

This post covers what I built, why each piece exists, and, more usefully, the things that broke along the way. The full configuration is open source: github.com/lamepic/homelab.

The goal

I wanted to learn the parts of DevOps you don't really learn from tutorials: networking you can't see, certificates, auth, deploys and backups. So I set myself one rule. Everything is in git, and nothing is exposed by accident.

Step 1: Close the doors

The first thing I did was check what was actually reachable. Docker has a habit that surprises a lot of people: published ports bypass the host firewall. Docker writes its own iptables rules, so ufw can say a port is closed while Docker has it open to the world.

The fix was to stop publishing ports at all. Every app now listens only on the server's Tailscale IP, a private network that only my own devices are on. No ports are forwarded on my router. When something does need to be public (n8n webhooks), Tailscale Funnel exposes exactly one hostname.

Step 2: One front door

Instead of remembering server:9000, server:3001 and so on, everything goes through Caddy, a reverse proxy. Caddy looks at the hostname you asked for (photos.home.example.com) and routes you to the right container over a shared Docker network.

The part I'm most happy with is HTTPS. My app hostnames point at a private IP, so Let's Encrypt can't reach them to verify them. Caddy instead uses the DNS-01 challenge: it proves I own the domain by creating a DNS record through Cloudflare's API, and gets one wildcard certificate that covers every app, present and future.

Step 3: One login

Some of my apps had no login at all. I added Authelia in front of everything: Caddy asks it about every request ("is this person logged in?") before passing the request along. That's a password plus a second factor (TOTP or a passkey), once, for every app.

For apps that support it, Authelia also acts as an OpenID Connect provider, the same protocol behind "Sign in with Google." Portainer now has a "Login with Authelia" button and no password of its own.

Step 4: Git, Renovate and CI/CD

Every app is a Docker Compose project in one repo. Every image is pinned to an exact version, because latest is a promise that something will change without telling you. Renovate opens a pull request each week with updates, grouped where versions must match. Major versions wait for me to approve them.

Merging is deploying. A self-hosted GitHub Actions runner on the server picks up pushes to main and redeploys only the stacks whose files changed. The runner connects out to GitHub, so the server still has no open ports. Pull requests are checked on GitHub's own machines, so code that hasn't been merged never runs on my server.

Step 5: Backups I've actually restored

Each night, a script takes clean database dumps (copying a live database's files can capture a half-written state). Then restic, through Backrest's web UI, encrypts everything and sends it to Cloudflare R2. It keeps 7 daily, 4 weekly and 6 monthly snapshots. Because only changes get uploaded, my 3 GB photo library costs nothing: it fits in R2's free tier.

Then I did the important part: I restored a database from R2 into a throwaway container and compared all 43 tables row by row. A backup you haven't restored is just a hope.

The fun part: what broke

I broke a deploy with a cleanup. I pruned unused Docker images while the runner was in the middle of pulling a 2 GB n8n update. The pull failed. The old version kept running, so nothing went down, but it taught me to check for a running deploy before touching images.

My SSO logged me out constantly. It turned out Authelia keeps sessions in memory, and I was restarting it a lot while configuring it. Every restart logged everyone out. I moved sessions to Valkey (an open-source Redis), then proved the fix by restarting Authelia and checking that my session survived.

My firewall rule looked right and wasn't. My first "tailnet only" rule in Caddy used respond, which Caddy runs after its handle blocks, so traffic from outside the tailnet still reached apps. I only caught it because I tested from outside. Now the rule is the first handle block.

Containers couldn't reach the login server. OIDC requires Portainer to talk to Authelia directly, server to server, and my "tailnet only" rule (correctly) blocked it. The fix was a Docker network alias, so the login hostname resolves straight to Caddy inside Docker, plus an exception scoped to just that one hostname and that one subnet.

My dashboard leaked zombie processes. Its web server ran as PID 1 inside the container and never cleaned up after its own health checks. One line, init: true, fixed it.

What I'd tell someone starting

  • Test from the outside. Every security bug I found, I found by trying to break in.
  • Pin versions. Updates should be a decision, not a surprise.
  • Restore your backups at least once.
  • Write it down as you go. These notes became this post, and the runbook in the repo.

The repo has a setup guide if you want to run it yourself: github.com/lamepic/homelab.