Case Study Self-Hosted Platforms In Production (self-hosted on Hetzner)

Porch: A Self-Hosted, Multi-Cloud Social Platform

TL;DR: A private, invite-only social platform (photos, videos, stories, messaging) built as a Go + Next.js stack and designed to be self-hosted from a single command. Passwordless auth, FFmpeg media transcoding, MinIO object storage, Web Push without Firebase, and a Caddy reverse proxy that provisions its own TLS — all running live on a Hetzner box I provision, deploy, and maintain myself.
👤Role: Sole engineer, architect, and operator
📏Scope: Full-stack platform + provisioning + infrastructure

📸Snapshot

Business Objective

A private group needed a place to share photos, videos, and stories without handing their data, contacts, or attention to an ad-funded platform. The goal was a social app they could actually own — running on their own server, with no third-party cloud dependency.

Primary Technical Outcome

A complete, self-hostable platform that goes from a fresh cloud VM to a TLS-secured production site in one command, with passwordless sign-in, automatic media transcoding, and nightly backups.

My Role

Sole engineer and operator — product, Go backend, Next.js frontend, media pipeline, the provisioning/deploy scripts, and the live Hetzner infrastructure.

Key Metrics

  • 7-service Docker Compose stack (Postgres, Redis, MinIO, API, worker, web, Caddy)
  • One-command provisioning to Hetzner, AWS, or GCP — same bootstrap, same deploy
  • Live and self-hosted; ~224 commits solo

📋Context

Porch is an invite-only platform (admin-issued one-time invite links) for a close-knit group. Because the whole point is data ownership, "just deploy it to a PaaS" was a non-goal — it had to be genuinely self-hostable by one person, cheaply, with no managed services behind it.

⚠️Problem

Symptoms

  • Mainstream social platforms monetize attention and data; there was no private alternative the group controlled
  • Self-hosting normally means hours of manual server setup, TLS wrangling, and brittle deploys
  • Media-heavy apps need transcoding and object storage, which are easy to get wrong

Root Causes

  • Ownership requires self-hosting, and self-hosting is usually too operationally heavy for one person to run reliably
  • Passwords are both a UX burden and a liability for a small private app

🔒Constraints & Requirements

Constraints

  • Must run cheaply on a single small VM (Hetzner cx22, ~€5/mo) with no managed services
  • One operator — provisioning, deploys, and backups must be scripted and idempotent
  • No vendor lock-in — the same artifact must deploy to Hetzner, AWS, GCP, or any Ubuntu host

Success Criteria

  • Fresh VM to TLS-secured production in a single command
  • No passwords; no third-party auth provider
  • Media uploads transcoded and served without an external CDN

🎯Strategy

Options Considered

  1. Managed PaaS + managed DB + S3 + a video API: Lean on hosted services for everything
    Pros: Fastest to stand up; little ops Cons: Defeats the data-ownership goal, recurring cost, vendor lock-in
    Why not chosen: Why not chosen — ownership and cost were the whole point
  2. Kubernetes: Orchestrate the services on a cluster
    Pros: Scales, declarative Cons: Massive operational overhead for a single-box, single-operator app
    Why not chosen: Why not chosen — wrong tool for the scale; Compose is enough
  3. Docker Compose on one VM, scripted provisioning (CHOSEN): One bootstrap + deploy script, Caddy for TLS, Compose for the stack
    Pros: Cheap, portable, one person can run it, no lock-in Cons: A single box isn't built for horizontal scale — fine for this workload, but it would need rework to outgrow one server
    Why chosen: Why chosen — matches the ownership, cost, and single-operator constraints exactly

🏗️Architecture

System Components

  • API (Go 1.25 / Gin / GORM): REST API on :8080 behind Caddy; auth, feed, media, messaging.
  • Worker (Go): background processor sharing the API image; FFmpeg transcoding and notifications via a River (Postgres-backed) queue.
  • Frontend (Next.js 15 / TypeScript / shadcn/ui / TanStack Query): PWA on :3000.
  • PostgreSQL 16 + Redis 7: primary store and cache.
  • MinIO: S3-compatible object storage for media and nightly backups.
  • Caddy 2: reverse proxy and automatic TLS; routes /api/* and /media/* to the API, everything else to the web app.

Data Flows

  • Upload → API stores original in MinIO → worker transcodes via FFmpeg → multiple renditions written back → served through /media/*.
  • Sign-in → magic-link or SMS OTP issues a 15-minute access JWT + 7-day refresh token; no passwords stored.
  • Nightly cron → Postgres dump + media tarball → gzipped → uploaded to a MinIO bucket with 30-day lifecycle expiry.

🛡️Security

Threat Model
  • Account takeover via stolen magic link: tokens are single-use, short-lived, and bound to a normalized email
  • Data exposure in a cloud breach: there is no cloud — data lives on the operator's own box and MinIO
  • Open registration abuse: access is invite-only via admin-issued one-time tokens

Controls Implemented

  • Passwordless auth with single-use, time-limited magic-link tokens (32-byte crypto/rand) and SMS OTP
  • Short-lived access JWTs (15 min) with separate 7-day refresh tokens
  • UFW firewall configured at bootstrap (22/80/443 only; 443/udp for HTTP/3)
  • Caddy security headers (HSTS preload, nosniff, SAMEORIGIN, strict referrer) and a removed Server header
  • Secrets generated per-environment with openssl rand; never committed; excluded from rsync on deploy

Verification

  • Auth service covered by Go unit tests
  • Internal services (Postgres, Redis, MinIO) bound to the Docker internal network, never published

⚙️Operations

Observability

  • Structured backend logs; Caddy access logs with rolling retention
  • Per-service Docker health checks gate startup order

Incident Response

  • Bad deploy: re-run deploy.sh against the previous commit; data volumes persist across rebuilds
  • Restore: pull the latest gzipped dump from the MinIO backups bucket and reload

Cost Controls

  • Runs on a single ~€5/mo Hetzner cx22; no managed DB, CDN, or object-storage bill
  • Self-hosted MinIO instead of S3; River queue instead of a separate broker

💡Lessons Learned

What Worked

  • One bootstrap script + one deploy script made the app genuinely portable across three clouds
  • Caddy's automatic TLS removed an entire class of operational toil
  • Staged DB-then-app startup eliminated first-deploy crash loops

What I Would Do Differently

  • Add an automated rollback path to deploy.sh rather than re-deploying a previous commit by hand
  • Broaden test coverage beyond the auth service

Playbook (Reusable Principles)

  • For single-operator apps, optimize for one person running it: scripted, idempotent, cheap, no managed services
  • Let the reverse proxy own TLS — it deletes a recurring operational chore
  • Stage data services and migrations before the app to avoid first-boot crash loops

Porch is the clearest expression of a pattern that runs through most of my work: build the product and own the infrastructure it runs on. It’s a full social platform — feed, stories, video, direct messages — but the engineering story is as much about how it’s operated as what it does. One person provisions the server, deploys the stack, and keeps it running, on hardware that costs about five euros a month, with no managed services behind it.

The provisioning and deploy tooling that makes that possible is itself reusable across my other self-hosted projects — see the provisioning & deploy agent and the one-command self-hosting runbook.

📦Artifacts

Selected Technical Details


                # deploy.sh — start data services first, wait for health, migrate, THEN bring up the app.
# Avoids the API crash-looping against an empty database on first deploy.
docker compose -f docker-compose.prod.yml --env-file .env up -d postgres redis
for i in $(seq 1 30); do
  if docker exec porch-postgres pg_isready -U "$DB_USER" &>/dev/null; then break; fi
  sleep 3
done
docker run --rm --network porch_internal -v "$PWD/server/migrations:/migrations" \
  migrate/migrate -path=/migrations -database "$DB_URL" up
docker compose -f docker-compose.prod.yml --env-file .env up -d