← The setup

Currently live

Stream

A live streaming platform with its own player, chat, accounts, and payments — served from a server in my house rather than a platform that owns the audience.

The live player with chat
internal network · no published ports OBS encoder Viewer browser rtmp · 1935 https · 443 nginx-rtmp TLS termination · HLS packaging · static player certbot 12h loop auth Go · JWT · SMTP chat Go · WebSocket payments Go · Stripe postgres 16 healthcheck-gated tip event

Ingress

One door in.

Exactly one container is published to the host. nginx-rtmp terminates TLS, accepts the RTMP push from OBS on 1935, and serves the HLS segments and static player over 443.

Services

Three Go services, none of them reachable.

Auth, chat, and payments sit on a private bridge network with no port mapping at all. Nothing outside the host can address them directly — every request arrives proxied, or it does not arrive.

State

One database, gated on readiness.

Postgres 16 is the only stateful service. Auth and payments declare a health-gated dependency, so neither starts issuing queries until the database reports ready — no boot-order race to lose.

Internal calls

Services talk sideways, not through the edge.

When a tip clears, payments calls the chat service directly across the internal network to push the event into the live WebSocket room. That traffic never touches the public interface.

Renewal

Certificates renew themselves.

A certbot loop wakes every twelve hours. On a successful renewal its deploy hook opens the Docker socket and sends nginx a HUP — a reload, not a restart, so no viewer drops mid-stream.

Data model

Ten tables, one hub.

Everything a person owns points back to users. Deletion is chosen per relation: sessions, tokens and bans cascade, because they mean nothing without the account; chat messages, tips and schedule events set null, because the history should outlive it.

N 1 N 1 N 1 N 1 N 1 N 1 N 1 N 1 users id PK username email password_hash role email_verified token_version created_at watch_sessions id PK user_id FK started_at ended_at duration_seconds chat_messages id PK user_id FK username message is_authenticated created_at tips id PK user_id FK display_name amount_cents currency stripe_session_id status completed_at schedule_events id PK created_by FK title start_date end_date event_type category stream_keys id PK key_value name is_active last_used_at chat_settings id PK cleared_at banned_users id PK user_id FK banned_by FK username ip_address reason expires_at is_active password_reset_tokens id PK user_id FK token expires_at used_at email_verification_tokens id PK user_id FK token expires_at used_at no relations one row, no relations on delete cascade on delete set null PK primary · FK foreign key N rows per 1 user key columns shown

The physical layer

Three machines, one hop to the viewer.

No cloud encoder, no managed origin. The signal leaves a capture card, gets encoded on a dedicated box, and is served from a workstation I own and administer.

  1. Source

    Gaming PC

    • Ryzen 9 9950X3D
    • RTX 5090 · 32GB
    • 32GB DDR5
  2. Capture & encode

    Streaming PC

    • Ryzen 9 5900X
    • RTX 3070 Ti
    • Elgato 4K60 · OBS
  3. Origin

    Server

    • HP Z440 · Xeon E5-2690 v4
    • 64GB ECC · 14 cores
    • Ubuntu Server · RAID

A problem worth solving

Reloading nginx without a shell.

Certificates expire every ninety days. Renewing them is the easy part — the hard part is telling the running proxy to pick up the new files without dropping anyone mid-stream.

A restart would work and would also kill every open connection. The correct signal is SIGHUP, which makes nginx reload its configuration and certificates in place. But the certbot container has no Docker CLI to send it with, and installing one to run a single command each quarter is the kind of dependency that rots.

So the deploy hook speaks to the Docker daemon directly: a raw HTTP request over the mounted Unix socket, written against the standard library and nothing else. Thirty lines, no package manager, no drift.

deploy hook · Python stdlib only
request = (
    "POST /containers/nginx-rtmp/kill?signal=HUP HTTP/1.1\r\n"
    "Host: localhost\r\n"
    "Content-Length: 0\r\n"
    "\r\n"
)

sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect("/var/run/docker.sock")
sock.sendall(request.encode("ascii"))
response = sock.recv(1024).decode("ascii", errors="replace")
sock.close()

return 0 if " 204 " in response else 1

Hardening

I audited my own code and wrote down what I found.

A permissive CORS default. A WebSocket handshake that accepted every origin. Database calls with no timeout, able to exhaust the connection pool under load. Each one found in my own code, documented with its file and line, and fixed.

Shipping software is table stakes. Going back through it adversarially, writing down the failure mode in plain language, and closing it out is the part that actually keeps a service up.

Tech specs

Services

Ingress
nginx-rtmp — TLS, RTMP 1935, HLS packaging
Auth
Go 1.21 — JWT sessions, email verification
Chat
Go 1.21 — WebSocket rooms, moderation
Payments
Go 1.21 — Stripe checkout and webhooks
Database
PostgreSQL 16

Infrastructure

Orchestration
Docker Compose, two bridge networks
Isolation
One published container; services unroutable
TLS
Let's Encrypt, self-renewing via deploy hook
Resilience
Health-gated startup, restart policies
Host
Ubuntu Server on owned hardware

Scope

Role
Sole developer and operator
Status
Live in production
Backend
~3,500 lines of Go across three services
Surface
Playback, chat, accounts, payments