Skip to content

Homelab setup#

This page covers everything between "the stack starts on localhost" and "I trust this enough to keep my data in it for a year." The target is a Linux host on your home network — a NUC, a Pi 5, a small VM on a Proxmox box, something at that scale.

The pieces you'll set up, in roughly the order they matter:

  1. A stable hostname and DNS for the stack.
  2. Caddy with real TLS instead of plain HTTP.
  3. Production-mode security defaults in .env.
  4. A systemd unit so the stack comes up after a reboot.
  5. Resource sizing so you don't oversubscribe the host.
  6. Optional reverse-proxy and remote-access patterns.

This page assumes you've already worked through Docker Compose reference and have the stack running on plain HTTP.

DNS and hostnames#

The single-origin design (see section landing) means the stack lives at one hostname. Pick one before you do anything else — changing it later means re-issuing certificates and reseeding the session cookie origin.

Three patterns work:

Pattern Cert source Reachable from
Local LAN only (tickets.lan) Self-signed or local CA Devices on the LAN with DNS pointing to the host
Public domain + internal IP (tickets.example.com192.168.1.50) Let's Encrypt via DNS-01 challenge Anywhere with DNS resolution; not reachable from the public internet
Public domain + public IP Let's Encrypt via HTTP-01 challenge The public internet

For most homelabs, public domain + internal IP is the sweet spot. You get real TLS via Let's Encrypt's DNS-01 challenge, the cert auto-renews, and the service stays on your LAN. Caddy automates the DNS-01 dance if your DNS provider is supported.

If you really do want the stack reachable from anywhere, terminate TLS at Caddy and front it with whatever VPN, Cloudflare Tunnel, or reverse-proxy layer you trust. Ticket Board itself has no IP allowlist — assume anything that hits Caddy will hit the login form.

Caddy with real TLS#

The default Caddyfile disables auto-HTTPS so the stack can run on plain HTTP for local dev:

{
    auto_https off
}

:80 {
    # ... handlers ...
}

For a homelab deploy, replace those two lines:

tickets.example.com {

    log {
        output stdout
        format filter {
            wrap json
            fields {
                request>uri      regexp "(/mcp/bot/)[^/?#]+([/?#].*)?$" "${1}<redacted>${2}"
                request>orig_uri regexp "(/mcp/bot/)[^/?#]+([/?#].*)?$" "${1}<redacted>${2}"
            }
        }
        level INFO
    }

    @mcp_path path_regexp ^/mcp/bot/
    handle @mcp_path { reverse_proxy backend:8000 }
    handle /api/v1/*  { reverse_proxy backend:8000 }
    handle /healthz   { reverse_proxy backend:8000 }
    handle /readyz    { reverse_proxy backend:8000 }
    handle            { reverse_proxy frontend:3000 }
}

The differences from the default:

  • Drop the auto_https off global block.
  • Replace :80 with your domain.
  • Keep the log filter — it's the MCP token redaction (see below).

Caddy will obtain a Let's Encrypt certificate the first time the proxy starts. For HTTP-01 (the default), port 80 must reach the host from the public internet for the duration of the challenge. For DNS-01, configure your DNS provider's API credentials per the Caddy docs; you'll typically add an env var like CLOUDFLARE_API_TOKEN to .env and use a DNS-enabled Caddy build.

You'll also want a redirect from HTTP to HTTPS. Caddy adds this automatically when auto_https is enabled and you've named the site by hostname rather than port.

MCP token redaction#

The MCP transport puts the bot token in the URL path: /mcp/bot/tkb_<32 chars>/mcp. Anything that logs request URIs — Caddy's access log, an upstream load balancer, a CDN — will capture full bot credentials unless you strip them first.

The Caddyfile does this with the filter log encoder. The regexp field filter rewrites the request>uri and request>orig_uri fields before the JSON line is emitted:

fields {
    request>uri      regexp "(/mcp/bot/)[^/?#]+([/?#].*)?$" "${1}<redacted>${2}"
    request>orig_uri regexp "(/mcp/bot/)[^/?#]+([/?#].*)?$" "${1}<redacted>${2}"
}

The result in the access log is "uri": "/mcp/bot/<redacted>/mcp" — not the literal token. The backend's structlog middleware applies the same redaction to its own log lines.

If you put another proxy in front of Caddy (Cloudflare Tunnel, nginx, an upstream LB), apply the same redaction there before logs leave the box. The backend cannot police logs it doesn't write.

Token redaction is load-bearing

The MCP URL is the credential. A log line containing a full tkb_... token in the URL is equivalent to leaking that bot's password. Test the redaction immediately after any Caddyfile or proxy-config change — see Release checklist step 7.

Custom HTTP ports#

If the host already runs something on 80/443 (Pi-hole, a router admin UI, another homelab service), override the Caddy port bindings in .env:

HTTP_PORT=8080
HTTPS_PORT=8443

Caddy still binds 80 and 443 inside the container — only the host-side ports change. The trade-off is that ACME HTTP-01 challenges won't reach Caddy on port 80, so you'll need DNS-01 for TLS or self-signed certs.

Production-mode .env#

For a homelab deploy with real TLS, change these from their dev defaults:

ENV=prod
COOKIE_SECURE=true
DOCS_ENABLED=false
SECRET_KEY=<32 hex bytes, fresh  do NOT reuse the example>
POSTGRES_PASSWORD=<strong random  do NOT reuse the example>

The backend refuses to start with ENV=prod and COOKIE_SECURE=false. That's intentional: if Caddy is terminating TLS, you want the session cookie marked Secure, and if Caddy is not terminating TLS, you should not be in prod mode. See Environment variables for the full list of security-relevant settings.

There is no startup check on SECRET_KEY; any non-empty value boots, including the documented placeholder. Rotating it is on you.

Generate fresh secrets:

# SECRET_KEY (32 bytes, hex)
python3 -c "import secrets; print(secrets.token_hex(32))"

# POSTGRES_PASSWORD (32 bytes, URL-safe base64)
python3 -c "import secrets; print(secrets.token_urlsafe(32))"

Treat .env like a private key. Mode 600, do not commit it, and copy it onto the host with scp or a secrets manager rather than pasting it into a Slack DM.

Auto-start with systemd#

For a stack you actually depend on, a systemd unit is worth the five minutes. After a reboot or a kernel update, the stack comes back without you.

Create /etc/systemd/system/ticket-board.service:

[Unit]
Description=Ticket Board (Docker Compose stack)
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/ticket-board
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=300

[Install]
WantedBy=multi-user.target

Adjust WorkingDirectory to wherever the repo lives on the host. Then:

sudo systemctl daemon-reload
sudo systemctl enable --now ticket-board.service
sudo systemctl status ticket-board.service

The Type=oneshot + RemainAfterExit=yes pattern is right for Compose stacks: docker compose up -d returns immediately after starting the containers, and systemd treats the unit as "active" until you stop it. Container-level restarts are handled by Docker (restart: unless-stopped in docker-compose.yml), not systemd.

To take the stack down without disabling the unit, use sudo systemctl stop ticket-board.service. To reload after editing the Compose file or .env, restart the unit — it will re-run docker compose up -d, which detects changed config and recreates affected containers.

Wait for the DB before declaring ready

The 300-second TimeoutStartSec is generous because docker compose up -d itself waits on depends_on: condition: service_healthy and service_completed_successfully — Postgres must come up and migrations must finish before the call returns. systemd is just waiting on Compose. If your host is fast and your DB is small, tighten it; if you see the unit time out, loosen it.

Logs#

journalctl -u ticket-board.service shows only the unit lifecycle. For the actual application logs, you still want docker compose logs:

cd /opt/ticket-board
docker compose logs -f backend
docker compose logs --tail=200 proxy

If you want application logs in journald, configure the Docker daemon's default log driver to journald in /etc/docker/daemon.json. The trade-off is a noisier journal and weaker docker compose logs output.

Resource sizing#

Ticket Board is small. The sizing question is mostly "how much Postgres can fit?", because the application processes are nearly free.

Component RAM Notes
Postgres 256 MB minimum, 1 GB comfortable Most of this is shared buffers + OS page cache for indexes.
Backend (uvicorn × 2 workers) 200–300 MB total Each worker is ~100 MB resident. Increase workers if you have spare CPU.
Frontend (Next.js standalone) 150–250 MB Mostly the Node runtime.
Caddy 30–60 MB Unusually lean for a TLS-terminating proxy.
Total stack ~1 GB comfortable, 512 MB tight

CPU usage is bursty during ticket transitions, search queries, and webhook fan-out, but idle is near-zero. A 2-core / 2 GB VM is plenty for a single operator. A Pi 5 with 4 GB has headroom.

Disk usage grows steadily with the audit log. The audit_event table has no retention policy in v1 (see adr/0005-mixed-soft-delete-policy.md); plan on a few hundred MB per year of active use, more if you have chatty bots. The tool_invocation_log table is purged at TOOL_INVOCATION_LOG_RETENTION_DAYS (default 90 days).

Tuning Postgres for a small host#

The defaults in postgres:16-alpine are fine for a few hundred users — and you have one. If you're memory-constrained, add a custom postgresql.conf overlay:

# docker-compose.override.yml
services:
  postgres:
    command: postgres -c shared_buffers=128MB -c effective_cache_size=384MB -c work_mem=8MB

There's no need to chase ms-level query optimization for a single-operator workload. If a query is slow, fix the query.

Remote access patterns#

Depending on how exposed you want the stack to be:

LAN only#

Default. Caddy listens on the LAN IP, DNS resolves the hostname locally (/etc/hosts, your router's DNS, a local DNS server, or a .lan MagicDNS via something like Tailscale).

Tailscale / WireGuard#

Caddy listens on the Tailscale IP of the host (or all interfaces, with firewall rules elsewhere). The stack is reachable from any Tailscale-joined device, including your phone, without touching the public internet. Use Tailscale MagicDNS for stable hostnames.

This is the recommended pattern for a homelab Ticket Board.

Cloudflare Tunnel#

Put a Cloudflare Tunnel in front of Caddy. The tunnel terminates TLS at Cloudflare's edge, so you can simplify the Caddyfile to plain HTTP listening on the tunnel — but you must apply MCP token redaction at the tunnel layer because Cloudflare's logs would otherwise capture full tokens. Cloudflare Logpush with a Workers transform is the usual approach.

Public domain, public IP#

The hardest option to get right. You need:

  • Port 80/443 forwarded to the host from your router.
  • DDNS or a static IP, and DNS pointing at it.
  • A bastion mindset — expect credential-stuffing traffic on the login form within hours of opening port 443 to the public internet.
  • A plan for per-actor REST/MCP rate limiting once the middleware lands; today only the login endpoint is throttled (per-IP and per-username sliding window).

If you have a choice, prefer Tailscale. It's significantly less work and significantly safer.

Operational habits#

A few things to do, or not do, once the stack is live:

  • Run the release smoke checklist after every upgrade. Ten minutes. Catches the things unit tests can't see.
  • Schedule backups before you need them. See Backup and restore. A backup taken after a disaster isn't a backup.
  • Watch docker compose logs backend after upgrades. The first 30 seconds of a new build will tell you if you got the env vars right; the redacted startup log dumps the resolved settings.
  • Do not edit Postgres directly. Use the REST API, the MCP tools, or the admin UI. Direct UPDATE and DELETE against tables like audit_event are explicitly revoked by migration, and direct domain writes bypass the audit trail.
  • Rotate the seeded password the first time you log in. Change it under Settings → Security (/settings/security). The default changeme-please is documented; assume anyone with the source can guess it.
  • Outbound webhook delivery goes directly from the backend container. The host firewall must allow egress to your webhook receivers' IPs; there is no proxy egress configuration. See webhooks for the configuration UI and delivery semantics.

See also#

  • Troubleshooting — Caddy debugging, token redaction verification, login-cookie symptoms.