Production deployment
This guide takes a fresh Hetzner Cloud VPS and deploys Pia Server (cloud.pia.de) and the marketing site (pia.de) behind Caddy with automatic Let’s Encrypt TLS, fronted by GitHub Container Registry images and rolled out via GitHub Actions.
Prerequisites: A Hetzner Cloud server with Docker and Docker Compose installed, and a domain whose DNS you control.
1. DNS records
Section titled “1. DNS records”Point both domains at your server’s public IP.
| Type | Name | Value |
|---|---|---|
| A | pia.de | <server-ip> |
| A | cloud.pia.de | <server-ip> |
Caddy needs DNS to resolve before it can issue certificates. Verify propagation:
dig pia.de +shortdig cloud.pia.de +shortBoth should return your server IP.
2. Install Docker on the server
Section titled “2. Install Docker on the server”ssh root@<server-ip>curl -fsSL https://get.docker.com | shdocker --versiondocker compose version3. Mount the persistent volume
Section titled “3. Mount the persistent volume”If you’re using a Hetzner Cloud Volume for persistent data, attach it from the Cloud Console then mount it on the server:
# Find the devicels /dev/disk/by-id/
# Format (first use only — destroys data!)mkfs.ext4 /dev/disk/by-id/scsi-0HC_Volume_<id>
# Mountmkdir -p /mnt/hc-volumemount /dev/disk/by-id/scsi-0HC_Volume_<id> /mnt/hc-volume
# Persist across rebootsecho '/dev/disk/by-id/scsi-0HC_Volume_<id> /mnt/hc-volume ext4 defaults 0 2' >> /etc/fstabIf your mount path differs from /mnt/hc-volume, update docker-compose.prod.yml after copying it to the server (step 8).
4. Authenticate Docker with ghcr.io
Section titled “4. Authenticate Docker with ghcr.io”The deploy workflow pushes images to GitHub Container Registry; the server pulls from it.
Create a GitHub Personal Access Token (classic) with the read:packages scope at github.com/settings/tokens, then on the server:
echo "<your-pat>" | docker login ghcr.io -u <your-github-username> --password-stdinYou should see Login Succeeded.
5. Production environment file
Section titled “5. Production environment file”mkdir -p /opt/piacat > /opt/pia/.env.prod << 'EOF'ASPNETCORE_ENVIRONMENT=ProductionDatabase__Provider=postgresqlDatabase__ConnectionString=Host=postgres;Database=pia;Username=pia;Password=<db-password>JWT_SECRET_KEY=<random-string-at-least-32-chars>ENCRYPTION_MASTER_KEY=<random-64-hex-chars>POSTGRES_PASSWORD=<db-password>EOFchmod 600 /opt/pia/.env.prodGenerate secure values:
openssl rand -base64 32 # JWT secretopenssl rand -hex 32 # Encryption master key (64 hex chars exactly)openssl rand -base64 24 # Postgres password6. GitHub: Repository secrets
Section titled “6. GitHub: Repository secrets”Generate an SSH deploy key locally:
ssh-keygen -t ed25519 -f pia-deploy-key -C "github-actions-deploy"# Press Enter twice — GitHub Actions can't enter a passphrase interactivelyAuthorize the public key on the server:
ssh root@<server-ip> "echo '$(cat pia-deploy-key.pub)' >> ~/.ssh/authorized_keys"Add these secrets at the repository’s secrets page:
| Secret | Value |
|---|---|
HETZNER_HOST | Your server IP |
HETZNER_USER | root (or your deploy user) |
HETZNER_SSH_KEY | Contents of the pia-deploy-key private key file |
GITHUB_TOKEN is built-in — no action needed for ghcr.io push authentication.
After adding the secrets, delete the local key files:
rm pia-deploy-key pia-deploy-key.pub7. Verify ghcr.io package visibility
Section titled “7. Verify ghcr.io package visibility”After your first successful workflow run (next step) the images will appear at github.com/orgs/Pia-Ai-dev/packages. If the repo is private the packages default to private too — the server’s docker login from step 4 handles authenticated pulls.
8. Deploy
Section titled “8. Deploy”-
Trigger via GitHub Actions (recommended for ongoing deploys)
Either push a change to a watched path (
src/Pia.Server/**,src/Pia.Web/**, …) onmaster, or run Actions → Deploy Production → Run workflow manually.The workflow:
- Builds and pushes the
pia-serverandpia-webimages to ghcr.io. - Copies
docker-compose.prod.ymlandCaddyfileto the server. - Runs
docker compose pull && docker compose up -don the server.
- Builds and pushes the
-
Manual first deploy (optional)
To deploy before your changes are merged to
master, copy the files and start the stack by hand:Terminal window # On your local machinescp docker-compose.prod.yml Caddyfile root@<server-ip>:/opt/pia/# On the serverssh root@<server-ip>cd /opt/piadocker compose -f docker-compose.prod.yml pulldocker compose -f docker-compose.prod.yml up -d
9. Verify
Section titled “9. Verify”On the server, all four containers (caddy, pia-server, pia-web, postgres) should be running:
docker compose -f /opt/pia/docker-compose.prod.yml psdocker compose -f /opt/pia/docker-compose.prod.yml logs # all logsdocker compose -f /opt/pia/docker-compose.prod.yml logs pia-server # one serviceFrom anywhere:
curl https://cloud.pia.de/health # should return 200 + JSONcurl -I https://pia.de # marketing site, 200Caddy obtains TLS certificates on first request — the very first hit may take a few seconds.
Troubleshooting
Section titled “Troubleshooting”Caddy can’t get TLS certificates
Section titled “Caddy can’t get TLS certificates”- Verify DNS records resolve to the server IP.
- Open ports 80 and 443 in the Hetzner Cloud Firewall.
- Read
docker compose logs caddy— ACME failures are noisy and self-explanatory.
pia-server won’t start
Section titled “pia-server won’t start”- Confirm Postgres is healthy:
docker compose ps postgres. - Re-check
.env.prod—POSTGRES_PASSWORDmust equal the password inDatabase__ConnectionString. - Read
docker compose logs pia-server.
Can’t pull images from ghcr.io
Section titled “Can’t pull images from ghcr.io”- Re-authenticate:
docker login ghcr.io(step 4). - Confirm the PAT has
read:packages. - Check package visibility at github.com/orgs/Pia-Ai-dev/packages.
Hetzner firewall
Section titled “Hetzner firewall”| Port | Protocol | Purpose |
|---|---|---|
| 22 | TCP | SSH |
| 80 | TCP | HTTP (Caddy redirect + ACME) |
| 443 | TCP | HTTPS |