Aller au contenu

Production deployment

Ce contenu n’est pas encore disponible dans votre langue.

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.

Point both domains at your server’s public IP.

TypeNameValue
Apia.de<server-ip>
Acloud.pia.de<server-ip>

Caddy needs DNS to resolve before it can issue certificates. Verify propagation:

Terminal window
dig pia.de +short
dig cloud.pia.de +short

Both should return your server IP.

Terminal window
ssh root@<server-ip>
curl -fsSL https://get.docker.com | sh
docker --version
docker compose version

If you’re using a Hetzner Cloud Volume for persistent data, attach it from the Cloud Console then mount it on the server:

Terminal window
# Find the device
ls /dev/disk/by-id/
# Format (first use only — destroys data!)
mkfs.ext4 /dev/disk/by-id/scsi-0HC_Volume_<id>
# Mount
mkdir -p /mnt/hc-volume
mount /dev/disk/by-id/scsi-0HC_Volume_<id> /mnt/hc-volume
# Persist across reboots
echo '/dev/disk/by-id/scsi-0HC_Volume_<id> /mnt/hc-volume ext4 defaults 0 2' >> /etc/fstab

If your mount path differs from /mnt/hc-volume, update docker-compose.prod.yml after copying it to the server (step 8).

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:

Terminal window
echo "<your-pat>" | docker login ghcr.io -u <your-github-username> --password-stdin

You should see Login Succeeded.

Terminal window
mkdir -p /opt/pia
cat > /opt/pia/.env.prod << 'EOF'
ASPNETCORE_ENVIRONMENT=Production
Database__Provider=postgresql
Database__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>
EOF
chmod 600 /opt/pia/.env.prod

Generate secure values:

Terminal window
openssl rand -base64 32 # JWT secret
openssl rand -hex 32 # Encryption master key (64 hex chars exactly)
openssl rand -base64 24 # Postgres password

Generate an SSH deploy key locally:

Terminal window
ssh-keygen -t ed25519 -f pia-deploy-key -C "github-actions-deploy"
# Press Enter twice — GitHub Actions can't enter a passphrase interactively

Authorize the public key on the server:

Terminal window
ssh root@<server-ip> "echo '$(cat pia-deploy-key.pub)' >> ~/.ssh/authorized_keys"

Add these secrets at the repository’s secrets page:

SecretValue
HETZNER_HOSTYour server IP
HETZNER_USERroot (or your deploy user)
HETZNER_SSH_KEYContents 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:

Terminal window
rm pia-deploy-key pia-deploy-key.pub

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.

  1. Trigger via GitHub Actions (recommended for ongoing deploys)

    Either push a change to a watched path (src/Pia.Server/**, src/Pia.Web/**, …) on master, or run Actions → Deploy Production → Run workflow manually.

    The workflow:

    1. Builds and pushes the pia-server and pia-web images to ghcr.io.
    2. Copies docker-compose.prod.yml and Caddyfile to the server.
    3. Runs docker compose pull && docker compose up -d on the server.
  2. 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 machine
    scp docker-compose.prod.yml Caddyfile root@<server-ip>:/opt/pia/
    # On the server
    ssh root@<server-ip>
    cd /opt/pia
    docker compose -f docker-compose.prod.yml pull
    docker compose -f docker-compose.prod.yml up -d

On the server, all four containers (caddy, pia-server, pia-web, postgres) should be running:

Terminal window
docker compose -f /opt/pia/docker-compose.prod.yml ps
docker compose -f /opt/pia/docker-compose.prod.yml logs # all logs
docker compose -f /opt/pia/docker-compose.prod.yml logs pia-server # one service

From anywhere:

Terminal window
curl https://cloud.pia.de/health # should return 200 + JSON
curl -I https://pia.de # marketing site, 200

Caddy obtains TLS certificates on first request — the very first hit may take a few seconds.

  • 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.
  • Confirm Postgres is healthy: docker compose ps postgres.
  • Re-check .env.prodPOSTGRES_PASSWORD must equal the password in Database__ConnectionString.
  • Read docker compose logs pia-server.
PortProtocolPurpose
22TCPSSH
80TCPHTTP (Caddy redirect + ACME)
443TCPHTTPS