NEW

Self-Hosting a Full-Stack App on a Raspberry Pi 5: The Bugs Taught Me More Than the Setup

By Yatheesh Nagella•September 2026•12 min read

I want to build a proper home lab eventually: a PC-based machine with real storage, real networking, and room to break things. But I didn’t want to spend that money before I understood what I was buying hardware for. So I started with one Raspberry Pi 5 and a small, real project to force me to learn the infrastructure side by hand.

The project is a self-hosted recipe box: a React frontend, a FastAPI backend and a Postgres database. I save cooking videos from YouTube (a lot of them regional and Indian-language), tag them by cuisine and ingredients, and keep a running log of how I changed the recipe each time I cooked it.

This isn’t a polished tutorial. It’s the honest version: why I picked each piece, and the bugs that cost me evenings. If you’re a beginner, the bugs are the useful part, because that’s where most people get stuck.

Why self-host at all?

A hosted platform would have gotten this app online faster. But then I’d have learned how to click through a dashboard, not how networks, containers and reverse proxies fit together. Constraints help here: a small ARM board, one user, and no budget for mistakes means every decision has to be justified.

The stack, and why each piece

1. Remote access: Tailscale, not port forwarding

I looked at three ways to reach the Pi from outside my house:

  • Port forwarding plus dynamic DNS
  • Cloudflare Tunnel
  • Tailscale, a mesh VPN

I chose Tailscale for three reasons. Only I need access, so there’s no reason to be public. It needs zero exposed ports, so there’s no attack surface on my home network. And it works behind CGNAT, which matters because most home ISPs no longer hand out a usable public IP.

I deliberately deferred Cloudflare Tunnel. It becomes the right add-on later, but only if a specific app has to be reachable by people who won’t install Tailscale.

2. Reverse proxy: Caddy, with Nginx on the “learn later” list

A reverse proxy sits in front of your apps and routes incoming requests to the right one. I picked Caddy because it’s a friendly first encounter with the concept: automatic HTTPS, a much shorter config syntax, and zero-downtime reloads. Nginx is the more common industry standard and I do want to learn it, but I picked Caddy for a good first experience with the idea, not as a permanent verdict.

3. Everything in Docker, one container per service

Caddy, Postgres, the FastAPI backend and eventually the React frontend each get their own container. Nothing is installed on the Pi’s OS directly. That gives me isolation (one app’s dependencies can’t break another’s), easy rebuilds, and a setup that looks like real-world infrastructure rather than a toy.

4. Backend: FastAPI over Flask or Django

FastAPI has a lighter footprint, which matters on Pi-class hardware. It also generates interactive API docs at /docs, validates requests through Pydantic, and supports async. Django’s admin and ORM felt like overhead I didn’t need yet. Flask is close, but lacks the automatic docs and validation.

5. Routing multiple apps: paths, not subdomains

Tailscale gives you one hostname per device, not free subdomains per app. So when I host a second app on the same Pi, Caddy will route by path (/recipe-box, /app2) instead of by subdomain. That avoids needing a separate Tailscale identity for every app.

The bugs (where the learning happened)

Bug 1: two passwords that should have been one

The backend couldn’t log in to Postgres. My .env file had this:

.env (broken)
POSTGRES_PASSWORD=Changing@me
DATABASE_URL=postgresql://recipes:changeme@postgres:5432/recipes

The DATABASE_URL was hardcoded separately and still held the old placeholder. Postgres was initialised with one password and the backend was trying another. The fix was to make them agree.

There was a second lesson hiding in there. The @ in a password breaks URL parsing, because @ is the separator between credentials and host. You either percent-encode it (%40) or, more simply, avoid it. I avoided it.

Bug 2: the 502 Bad Gateway and the meaning of “localhost”

This one taught me the most. Caddy returned 502 Bad Gateway for every request, and my config looked correct:

Caddyfile (broken)
:80 {
    reverse_proxy localhost:8000
}

The problem is that localhost inside a container means that container itself. It isn’t the Pi, and it isn’t the container next door. Caddy was dutifully looking for a backend on port 8000 inside the Caddy container, where nothing lives.

Fixing that alone wasn’t enough, and I found the real root cause with docker network ls and docker inspect. Caddy and the backend had been created by two separate docker-compose.yml files in two separate folders. Compose had quietly created two completely isolated networks, with no path between them.

The fix has two parts. Join the backend to Caddy’s network as an external network, then address it by service name instead of localhost.

backend/docker-compose.yml
services:
  backend:
    # ...
    networks:
      - caddy_default

networks:
  caddy_default:
    external: true   # created by the other compose project
Caddyfile (fixed)
:80 {
    reverse_proxy backend:8000
}

The mental model to keep

Containers are isolated by default. Networks are the bridges between them. On a shared network, Docker’s built-in DNS lets containers find each other by service name, so backend:8000 works and localhost:8000 can’t.

Bug 3: the backend that crashed on first boot

Every so often the backend died on startup, then worked fine on a restart. I had depends_on: postgres, so I assumed the ordering was handled. It isn’t. depends_on only waits for the container to start, not for Postgres to be ready to accept connections. Those can be several seconds apart.

The fix is a real healthcheck, and telling Compose to wait for it:

docker-compose.yml
services:
  postgres:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U recipes"]
      interval: 5s
      timeout: 3s
      retries: 10

  backend:
    depends_on:
      postgres:
        condition: service_healthy

Bug 4: accidentally exposing the app to my whole LAN

Caddy was published on 0.0.0.0:80. Nothing was on the public internet, but anyone on my home WiFi could reach the app directly without going through Tailscale. That’s wider than I intended. Since Tailscale Serve reaches Caddy through the Pi’s own loopback anyway, I bound the port to loopback only:

caddy/docker-compose.yml
ports:
  - "127.0.0.1:80:80"   # was "80:80", i.e. 0.0.0.0

Bug 5 (sort of): the red LED and the unresponsive Pi

One day SSH timed out and the Pi showed a red light. I power-cycled it and it came back cleanly. I never found a definitive root cause: rebooting clears the undervoltage history flag, so the evidence was gone. The likeliest suspects are a marginal power supply or cable (the Pi 5 draws more than earlier models) or a transient kernel or filesystem hang.

I can’t claim I solved this one. What it did was turn “I should think about disaster recovery” from an abstract idea into a task for this week.

Reproducibility: if the SD card died tomorrow

The recipe box code was in a private GitHub repo from day one. But my Caddy config existed only on the Pi’s SD card, committed nowhere. If the card failed, the reverse proxy setup would be gone with no record of it. I turned ~/caddy into its own git repo, with a .gitignore that excludes Docker’s generated data/ and config/ folders. That’s TLS state and runtime data that regenerates automatically and doesn’t belong in version control.

Secrets are the exception. .env files stay out of git in both repos, and the plan is to keep them in a password manager.

Backups: two different risks

I separated the risks, because they need different answers: losing the data in the database, and losing the whole setup. The git repos cover the second. For the first, the plan is:

  • A nightly pg_dump from cron.
  • Copy the dump off the Pi with rclone or rsync, to cloud storage or another device. A backup on the same SD card isn’t a backup.
  • Test restores periodically, since an untested backup isn’t a real backup.
  • Longer term, move from the SD card to a USB SSD. That doesn’t replace backups; it just lowers how often I’d need one.

In progress: CI/CD

I’m adding pytest for the backend using FastAPI’s TestClient, and I’m considering in-memory SQLite for fast test runs instead of hitting real Postgres. For deployment I’m going with a self-hosted GitHub Actions runner on the Pi itself, rather than a GitHub-hosted runner reaching in over Tailscale. It matches the “Pi pulls from GitHub” pattern I already use, and it needs no inbound exposure, since the Pi only makes outbound connections to GitHub.

What’s next: importing recipes from YouTube

This isn’t built yet, so treat it as the plan. Paste a YouTube link, pull the transcript with the YouTube transcript API (or Whisper for videos without captions), and have an LLM extract a structured recipe: ingredients with quantities, and steps.

The interesting part is regional cooking videos in Hindi, Tamil, Telugu, or code-switched Hindi-English. Whisper handles these reasonably well, and an LLM can extract structure directly from non-English or mixed-language transcripts without a separate translation step. The harder problem is informal measurement like ek katori or chutki bhar namak. Rather than force false precision, I plan to keep the original phrasing alongside a best-effort standard-unit estimate.

What I’d tell another beginner

  • Containers are isolated by default. When two containers can’t talk, check the networks before the config.
  • Started isn’t ready. Use healthchecks, not just depends_on.
  • Check what you’re actually listening on. 0.0.0.0 versus 127.0.0.1 is the difference between “my LAN” and “only me”.
  • Config that lives on one SD card is a risk. Commit it, and keep secrets out of the commit.
  • Back up off the device, and test the restore.

Next up: the backups, the CI runner, and a USB SSD. I’ll write those up too.

Tags

Raspberry PiHome LabDockerNetworkingPersonal Story
YN

Yatheesh Nagella

Software Engineer & Cloud Solutions Consultant specializing in multi-cloud architecture, DevOps automation, and scalable systems.