Skill v1.0.1
currentAutomated scan100/100+2 new
version: "1.0.1" name: deployment description: Deploy a Spiderly project to your own infrastructure with Docker, Caddy, and Terraform. Use when setting up production hosting for the .NET backend and Angular admin, configuring CI/CD, managing TLS with Cloudflare origin certificates, or laying out infrastructure-as-code for a Spiderly app.
Deployment
Spiderly is Docker-first by design. The recommended production setup is a single VPS running Docker Compose, fronted by Caddy and Cloudflare, with all infrastructure declared in Terraform.
Recommended stack
| Tier | Choice | Why | |
|---|---|---|---|
| Compute | Hetzner Cloud (or any VPS) | Predictable monthly cost, full control, no PaaS lock-in | |
| Orchestration | Docker Compose | Single-host simplicity; matches Spiderly's Docker-first philosophy | |
| Reverse proxy / TLS | Caddy v2 | Auto-config from Cloudflare origin certs, simple Caddyfile | |
| DNS / WAF / CDN | Cloudflare (orange-cloud) | DDoS protection, origin certs, Turnstile | |
| IaC | Terraform | Declarative; one source of truth for VPS + DNS + certs | |
| State backend | Cloudflare R2 | S3-compatible, free tier, encrypted at rest | |
| Container registry | GitHub Container Registry (GHCR) | Free for public/internal repos, native to GitHub Actions | |
| CI/CD | GitHub Actions | Build, push, SSH-deploy in one workflow file |
What to host where
- .NET Backend → Hetzner+Docker (recommended)
- Angular admin → Hetzner+Docker, same VPS as the backend, served by the same Caddy on a separate subdomain
- Next.js storefront (if applicable) → Vercel recommended. Next.js + Vercel gives you ISR, edge caching, image optimization, PPR, and instant preview URLs. Hetzner+Docker for SSR is viable but loses these features. Use Hetzner only if you need a single ops surface.
Compose stack shape
Backend/docker-compose.prod.yml (placed alongside the backend project):
services:caddy:image: caddy:2command: caddy run --config /etc/caddy/Caddyfile --watchports: ["80:80", "443:443"]volumes:- ./Caddyfile:/etc/caddy/Caddyfile:ro- ./certs:/etc/caddy/certs:ro- caddy_data:/datadepends_on:backend:condition: service_startedadmin:condition: service_healthyrestart: unless-stoppedbackend:image: ${BACKEND_IMAGE}environment:ASPNETCORE_ENVIRONMENT: ProductionASPNETCORE_HTTP_PORTS: "8080"AppSettings__Spiderly.Shared__ConnectionString: "Host=postgres;Port=5432;Database=${DB_NAME};Username=postgres;Password=${DB_PASSWORD};SSL Mode=Disable;"# ... plus storage / mail / OAuth env vars (see file-storage skill for the storage set)healthcheck:test: ["CMD", "curl", "-f", "http://localhost:8080/health"]interval: 30stimeout: 5sstart_period: 60sretries: 3depends_on:postgres: { condition: service_healthy }restart: unless-stoppedadmin:image: ${ADMIN_IMAGE}expose: ["80"]healthcheck:test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]interval: 30stimeout: 5sstart_period: 10sretries: 3restart: unless-stoppedpostgres:image: postgres:18environment:POSTGRES_DB: ${DB_NAME}POSTGRES_USER: postgresPOSTGRES_PASSWORD: ${DB_PASSWORD}volumes: [postgres_data:/var/lib/postgresql/data]healthcheck:test: ["CMD-SHELL", "pg_isready -U postgres"]interval: 10sretries: 5restart: unless-stoppedvolumes:caddy_data:postgres_data:
Key points:
- Only
caddybinds host ports.backendandadminare reachable only on the internal Docker network — Caddy reverse-proxies to them by service name. caddy.depends_onusescondition: service_healthyfor the admin container (so Caddy doesn't 502 before the inner Caddy has bound port 80) butcondition: service_startedfor the backend — the backend's/healthendpoint only goes green after EF migrations and warmup, and gating Caddy on that would block all traffic for 20–30 s on every restart.- Image tags come from CI via envsubst (
${BACKEND_IMAGE},${ADMIN_IMAGE}).
Caddy site blocks
Backend/Caddyfile — extracts compression + security headers into a (common) snippet so both site blocks stay DRY. Cloudflare also sets some of these (HSTS, Brotli) at its edge, but defense-in-depth at the origin is cheap and keeps origin→Cloudflare bandwidth compressed:
(common) {encode zstd gzipheader {Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"X-Content-Type-Options nosniffX-Frame-Options DENYReferrer-Policy strict-origin-when-cross-origin-Server}}api.<your-domain> {import commontls /etc/caddy/certs/origin.pem /etc/caddy/certs/origin-key.pemreverse_proxy backend:8080}admin.<your-domain> {import commontls /etc/caddy/certs/origin.pem /etc/caddy/certs/origin-key.pemreverse_proxy admin:80}
Both subdomains share a single Cloudflare origin cert with both names as SANs (set up in Terraform — see below).
Backend Dockerfile
Backend/Dockerfile (multi-stage SDK build → aspnet runtime):
FROM mcr.microsoft.com/dotnet/sdk:9.0.102 AS buildWORKDIR /srcCOPY ["<YourApp>.WebAPI/<YourApp>.WebAPI.csproj", "<YourApp>.WebAPI/"]# ... copy other csprojs, restore, copy source, publish ...RUN dotnet publish "<YourApp>.WebAPI/<YourApp>.WebAPI.csproj" -c Release -o /app/publish /p:UseAppHost=falseFROM mcr.microsoft.com/dotnet/aspnet:9.0.1WORKDIR /appRUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*COPY --from=publish /app/publish .USER appEXPOSE 8080ENTRYPOINT ["dotnet", "<YourApp>.WebAPI.dll"]
Pin patch versions (9.0.102 SDK, 9.0.1 runtime) — :9.0 floats and breaks reproducible builds.
Run as non-root. The aspnet image ships an app user (UID 1654). Add USER app after COPY for defense-in-depth — limits the blast radius of a container escape and matches the platform's sandbox model.
Log volumes: if you're using a Serilog File sink (not just Console) with a Hangfire log-archival job, see the Backups → Log archival section below for the full bind-mount-vs-Console-only trade-off. Greenfield deploys can stick with Console sink + Docker's json-file rotation and skip the volume entirely.
Angular admin Dockerfile
Frontend/Dockerfile (multi-stage Node build → Caddy alpine runtime):
FROM node:22-alpine AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run buildFROM caddy:2-alpineCOPY --from=build /app/dist/<YourApp>/browser /srvCOPY Caddyfile /etc/caddy/CaddyfileEXPOSE 80HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \CMD wget -q --spider http://localhost/ || exit 1
Frontend/Caddyfile (internal — runs inside the admin container):
:80 {root * /srvencode zstd gzip@hashedAssets {path_regexp hashed \.[a-f0-9]{8,}\.(js|css|woff2?|ttf|otf|svg|png|jpg|jpeg|webp|ico)$}header @hashedAssets Cache-Control "public, max-age=31536000, immutable"@indexHtml path /index.htmlheader @indexHtml Cache-Control "no-cache, no-store, must-revalidate"try_files {path} /index.htmlfile_server}
The try_files {path} /index.html line is the SPA fallback that lets the Angular router handle deep links.
Lockfile gotcha: npm ci requires package.json and package-lock.json to be in sync. If a stale dev-dep pins an older Angular major as a peer (e.g. @jsverse/transloco-keys-manager 5.x pins Angular 17 while your project is on Angular 19), npm ci fails. Bump the dev-dep to the matching major (e.g. transloco-keys-manager 6.x for Angular 19) rather than reaching for --legacy-peer-deps.
Terraform layout
Split files by provider concern; one provider, one or two files:
infrastructure/├── main.tf # required_providers + state backend├── variables.tf├── outputs.tf├── hetzner-firewall.tf # firewall, allowed ports├── cloudflare-dns.tf # api/admin A records├── cloudflare-origin-cert.tf # origin CA cert + private key└── cloudflare-zone.tf # zone + headers
main.tf providers:
terraform {required_version = ">= 1.5"backend "s3" {# Cloudflare R2 — S3-compatiblebucket = "<your-app>-terraform-state"key = "infrastructure/terraform.tfstate"region = "auto"skip_credentials_validation = trueskip_metadata_api_check = trueskip_region_validation = trueskip_requesting_account_id = trueskip_s3_checksum = trueendpoints = { s3 = "https://<account-id>.r2.cloudflarestorage.com" }}required_providers {cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.0" }hcloud = { source = "hetznercloud/hcloud", version = "~> 1.49" }tls = { source = "hashicorp/tls", version = "~> 4.0" }}}
Origin cert covering both api and admin subdomains (cloudflare-origin-cert.tf):
resource "tls_private_key" "origin" {algorithm = "RSA"rsa_bits = 2048lifecycle {prevent_destroy = true}}resource "tls_cert_request" "origin" {private_key_pem = tls_private_key.origin.private_key_pemsubject {common_name = "<your-domain>"organization = "<YourApp>"}dns_names = ["api.<your-domain>","admin.<your-domain>",]}resource "cloudflare_origin_ca_certificate" "origin" {csr = tls_cert_request.origin.cert_request_pemhostnames = tls_cert_request.origin.dns_namesrequest_type = "origin-rsa"requested_validity = 5475 # 15 yearslifecycle {prevent_destroy = true# Cloudflare normalizes hostname/CSR ordering server-side; ignore both to avoid spurious recreate.ignore_changes = [hostnames, csr]}}
Sensitive outputs (outputs.tf) so the deploy workflow can pull cert + key into GitHub Secrets:
output "origin_cert" {value = cloudflare_origin_ca_certificate.origin.certificatesensitive = true}output "origin_cert_key" {value = tls_private_key.origin.private_key_pemsensitive = true}
Retrieve with terraform output -raw origin_cert and paste into a GitHub Secret. Note: the API token must have Origin CA: Edit scope for cloudflare_origin_ca_certificate to work.
CI/CD
Two workflows — one per deploy unit. Both should share a concurrency group so they serialize on the same VPS:
# .github/workflows/deploy-backend.ymlname: Deploy Backendon:push:branches: [main]paths: ['Backend/**', '.github/workflows/deploy-backend.yml']concurrency:group: <your-app>-deploycancel-in-progress: falseenv:IMAGE_NAME: ghcr.io/<your-user>/<your-app>-backendADMIN_IMAGE_NAME: ghcr.io/<your-user>/<your-app>-admin
Steps (typical sequence):
- Run tests (gate the deploy on green tests).
- Build + push image to GHCR (
docker/build-push-action@v6with GHA cache). - SSH key setup +
ssh-keyscanto trust the host. - Run EF migrations via SSH tunnel to the VPS Postgres port (so prod schema updates before the new backend starts). Set the cleanup trap before opening the tunnel so an early ssh failure doesn't leak the trap:
``bash trap 'pkill -f "ssh -o ExitOnForwardFailure=yes -fN -L 5432" || true' EXIT ssh -o ExitOnForwardFailure=yes -fN -L 5432:127.0.0.1:5432 root@host for i in {1..30}; do nc -z localhost 5432 && break; sleep 1; done nc -z localhost 5432 || { echo "::error::SSH tunnel never came up after 30s"; exit 1; } ` The explicit nc check after the loop is what turns a timed-out tunnel into a clear failure — without it, dotnet ef` runs against a dead port and reports a confusing "connection refused" instead.
- Sync compose + Caddyfile with
envsubstto inject image tags + secrets, thenscpto/opt/<your-app>/. - Deploy:
ssh ... "docker compose pull backend && docker compose up -d backend caddy". Scope to just the services you're updating — unscopedup -dwill also bounce admin/postgres on every backend push, which is rarely what you want.
The admin workflow is similar but lighter: build → push → ssh → docker compose pull admin && docker compose up -d admin. Don't restart Caddy after an admin update — Caddy resolves admin:80 via Docker DNS at request time and picks up the new container automatically.
For migration mechanics (creating migrations, the dedicated *.Migrations startup-project pattern, why direct DDL on prod is forbidden), see the ef-migrations skill.
Backups
If you ship this stack to prod, you ship a backup with it. Postgres data lives in a Docker volume on a single VPS — disk failure, accidental docker volume rm, or terraform destroy all wipe it without a snapshot to restore from.
Database backups (required)
Daily pg_dump to a Cloudflare R2 bucket, 7-day retention both local and remote. ~24h RPO; if you need tighter, layer WAL archiving on top (separate skill).
1. R2 bucket — Terraform-managed. No chicken-and-egg here (only the state bucket has to be bootstrapped manually); manage data buckets like any other resource:
# infrastructure/cloudflare-r2.tfresource "cloudflare_r2_bucket" "db_backups" {account_id = var.cloudflare_account_idname = "<your-app>-db-backups"location = "EEUR"lifecycle {prevent_destroy = true}}
2. Backup script — `infrastructure/scripts/pg_backup_s3.sh` (deployed to /usr/local/bin/ on the VPS). Note the trap cleanup, the aws s3api list-objects-v2 --query for retention (structured + locale-safe vs parsing aws s3 ls with awk), and the per-iteration warn-on-failure inside the while subshell (where set -e does NOT propagate):
#!/usr/bin/env bashset -euo pipefailCONTAINER_NAME="<your-app>-postgres-1"DB_NAME="<your-db>"DB_USER="postgres"CLOUDFLARE_ACCOUNT_ID="<account-id>"S3_BUCKET="s3://<your-app>-db-backups"R2_ENDPOINT="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"RETENTION_DAYS=7LOCAL_BACKUP_DIR="/var/backups/postgresql"LOG_FILE="/var/log/<your-app>_pg_backup.log"TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)BACKUP_FILE="<your-app>_${TIMESTAMP}.sql.gz"LOCAL_PATH="${LOCAL_BACKUP_DIR}/${BACKUP_FILE}"log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"; }trap 'rc=$?; [[ $rc -ne 0 ]] && rm -f "$LOCAL_PATH" && log "Aborted, removed partial $LOCAL_PATH"; exit $rc' ERR INT TERMmkdir -p "$LOCAL_BACKUP_DIR"if ! docker exec "$CONTAINER_NAME" pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$LOCAL_PATH"; thenlog "ERROR: pg_dump failed"; exit 1filog "Backup created: ${LOCAL_PATH} ($(du -h "$LOCAL_PATH" | cut -f1))"if ! aws s3 --endpoint-url "$R2_ENDPOINT" cp "$LOCAL_PATH" "${S3_BUCKET}/${BACKUP_FILE}"; thenlog "ERROR: S3 upload failed"; exit 1filog "Uploaded to ${S3_BUCKET}/${BACKUP_FILE}"find "$LOCAL_BACKUP_DIR" -name "<your-app>_*.sql.gz" -mtime +"$RETENTION_DAYS" -deleteCUTOFF_ISO=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)BUCKET_NAME="${S3_BUCKET#s3://}"OLD_KEYS=$(aws s3api --endpoint-url "$R2_ENDPOINT" list-objects-v2 \--bucket "$BUCKET_NAME" \--query "Contents[?LastModified<'${CUTOFF_ISO}'].Key" \--output text 2>/dev/null || echo "")if [[ -n "$OLD_KEYS" && "$OLD_KEYS" != "None" ]]; thenfor KEY in $OLD_KEYS; doaws s3 --endpoint-url "$R2_ENDPOINT" rm "${S3_BUCKET}/${KEY}" \&& log "Deleted remote: ${KEY}" \|| log "WARN: failed to delete remote ${KEY}"donefi
3. VPS prerequisites. Add awscli and cron to cloud-init packages: so future server replacements have them. Configure R2 credentials in /root/.aws/credentials (same keys as the Terraform state backend — they're account-scoped):
[default]aws_access_key_id = <R2 access key>aws_secret_access_key = <R2 secret>
4. Cron entry (/etc/cron.d/<your-app>-pg-backup). Wrap with flock -n so a hung run doesn't get a second instance started 24h later:
0 2 * * * root /usr/bin/flock -n /var/lock/<your-app>-pg-backup.lock /usr/local/bin/pg_backup_s3.sh >> /var/log/<your-app>_pg_backup.log 2>&1
5. Restore — `scripts/db-restore.sh` (run from local). Lists server-side backups via SSH, prompts for selection, takes a safety dump first (with size check — refuses to proceed if pg_dump silently produced an empty file), then restores. Note set -euo pipefail inside the SSH heredocs (without it, a pg_dump failure inside a pipeline is masked by a successful gzip) and the OVERWRITE <db> confirmation phrase (a bare DB name is too easy to typo into):
#!/usr/bin/env bashset -euo pipefailSSH_ALIAS="<your-app>"CONTAINER="<your-app>-postgres-1"DB="<your-db>"REMOTE_DIR="/var/backups/postgresql"MIN_SAFETY_BYTES=1024trap 'echo "Interrupted — DB may be inconsistent. Latest safety snapshot is in $SSH_ALIAS:$REMOTE_DIR."' INT TERMssh -o ConnectTimeout=5 "$SSH_ALIAS" "docker exec $CONTAINER pg_isready -U postgres -d $DB" >/dev/null \|| { echo "Postgres not ready"; exit 1; }mapfile -t BACKUPS < <(ssh "$SSH_ALIAS" "ls -1t $REMOTE_DIR/<your-app>_*.sql.gz 2>/dev/null | xargs -n1 basename")[[ ${#BACKUPS[@]} -gt 0 ]] || { echo "No backups found"; exit 1; }for i in "${!BACKUPS[@]}"; do printf " %2d) %s\n" "$((i+1))" "${BACKUPS[$i]}"; doneread -rp "Pick: " PICK[[ "$PICK" =~ ^[0-9]+$ ]] && (( PICK >= 1 && PICK <= ${#BACKUPS[@]} )) || { echo "Invalid"; exit 1; }CHOSEN="${BACKUPS[$((PICK-1))]}"read -rp "Type 'OVERWRITE $DB' to confirm: " CONFIRM[[ "$CONFIRM" == "OVERWRITE $DB" ]] || { echo "aborted"; exit 1; }SAFETY="<your-app>_pre_restore_$(date +%Y-%m-%d_%H%M%S).sql.gz"ssh "$SSH_ALIAS" "set -euo pipefail; docker exec $CONTAINER pg_dump -U postgres $DB | gzip > $REMOTE_DIR/$SAFETY"SIZE=$(ssh "$SSH_ALIAS" "stat -c%s $REMOTE_DIR/$SAFETY")(( SIZE >= MIN_SAFETY_BYTES )) || { echo "Safety snapshot suspiciously small ($SIZE B) — aborting"; exit 1; }ssh "$SSH_ALIAS" "set -euo pipefaildocker exec $CONTAINER psql -U postgres -d postgres -c \"DROP DATABASE IF EXISTS \\\"$DB\\\" WITH (FORCE);\"docker exec $CONTAINER psql -U postgres -d postgres -c \"CREATE DATABASE \\\"$DB\\\";\"gunzip -c $REMOTE_DIR/$CHOSEN | docker exec -i $CONTAINER psql -U postgres -d $DB"echo "Restored. Safety snapshot at $SSH_ALIAS:$REMOTE_DIR/$SAFETY."
6. Restore drill — quarterly. A backup you've never restored from is not a backup. At least once a quarter, restore the latest dump into a throwaway local Postgres and verify schema + row counts. Calendar reminder.
Log archival (optional)
When to use it: you need long-term log retention beyond Docker's json-file rotation buffer (default ~150 MB rolling per container, configured in compose).
Pattern: a Hangfire recurring job watches /app/logs/, ships files older than N days to R2 once total > threshold, deletes locally.
To make /app/logs writable under USER app:
- Bind mount with chowned host dir (recommended):
volumes: - /var/log/<your-app>:/app/logsin compose, one-timemkdir -p /var/log/<your-app> && chown 1654:1654 /var/log/<your-app>on the VPS. Bind mounts respect host ownership; named volumes overlay the path with root-owned storage whichappcan't write to. - No File sink at all (simpler): rely on Console + Docker rotation. Drop the
/app/logsvolume entirely. Right answer for greenfield deploys.
Pitfalls
- Cookie domain across subdomains. Set
CookieDomainto.<your-domain>(leading dot) so cookies set byapi.<your-domain>are accepted byadmin.<your-domain>.SameSite=Laxis the right default for an admin SPA. - `ForwardLimit = 2`. With Cloudflare → Caddy → backend, your forwarded headers cross two proxies. The Spiderly scaffold ships
appsettings.Production.jsonwithForwardLimit: 2already set; if you sit behind Cloudflare, paste the current Cloudflare CIDR list intoTrustedProxyNetworks(refresh from https://www.cloudflare.com/ips/ periodically — Cloudflare adds ranges occasionally):
``json { "AppSettings": { "Spiderly.Shared": { "ForwardLimit": 2, "TrustedProxyNetworks": [ "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22", "2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32" ] } } } ``
When TrustedProxyNetworks is unset, Spiderly trusts RFC 1918 private ranges by default — fine for Docker-internal Caddy → backend traffic but not for the outermost Cloudflare → Caddy hop, which arrives over public IPs.
On the Terraform side (Hetzner firewall, etc.), use the `cloudflare_ip_ranges` data source instead of a hardcoded list — keeps the firewall in sync with Cloudflare's published ranges automatically:
```hcl data "cloudflare_ip_ranges" "this" {}
resource "hcloud_firewall" "main" { rule { direction = "in" protocol = "tcp" port = "443" source_ips = concat(data.cloudflare_ip_ranges.this.ipv4_cidrs, data.cloudflare_ip_ranges.this.ipv6_cidrs) } # ... } ```
The .NET TrustedProxyNetworks list still has to be hardcoded — there's no equivalent runtime data source short of fetching at startup. The hardcoded list and the Terraform data source describe the same set, so both rot at the same speed; pin a calendar reminder to refresh quarterly.
- CORS `FrontendUrl`. The backend's
AppSettings__Spiderly.Shared__FrontendUrlmust point to the admin subdomain, not a build-preview URL. Update it when you cut over from staging hosting. - First deploy ordering. The backend compose references
${ADMIN_IMAGE}. On a fresh VPS, that image must exist in GHCR before backend deploys, or the admin service definition fails to pull. Push admin first, or run the admin workflow once before the first backend deploy. - `docker compose up -d` scoping. Always scope to the services you're updating:
docker compose up -d backend caddyfor backend deploys,docker compose up -d adminfor admin deploys. Unscopedup -dbounces every service that's currently down or has a config change — including admin and postgres on a backend-only commit. The healthcheck-gateddepends_onmakes the unscoped form safe but not desirable. - Static assets caching. Hashed Angular bundles (e.g.
main.abc123.js) can be cached forever;index.htmlmust never cache, or users will get a stale shell after a deploy. The Caddyfile in this skill already handles both cases. - `tls_private_key` lives in Terraform state in cleartext. R2 encryption-at-rest is bucket-level; anyone with R2 API access (or a leaked state file) can read the key. Scope the R2 token tightly, audit who can pull state, and never copy
terraform.tfstateto laptops or shared drives. Rotating the cert means a freshterraform applyfollowed by re-pasting the new outputs into GitHub Secrets — plan a maintenance window. Addlifecycle { prevent_destroy = true }to thetls_private_keyandcloudflare_origin_ca_certificateresources so accidental config changes can't trigger a silent rotation. Also addignore_changes = [hostnames, csr]on the cert — Cloudflare normalizes hostname/CSR ordering server-side, otherwise every plan would show a phantom recreate. - Postgres data on Docker named volumes is not durable across server replacement. If the VPS itself is Terraform-managed (
hcloud_server) and gets recreated for any reason — image change, server_type change, accidental destroy — the local Docker named volumepostgres_datagoes with it. Addlifecycle { prevent_destroy = true }to thehcloud_serverresource. For genuine durability, attach anhcloud_volumeand bind-mount it into postgres (/var/lib/postgresql/data); volumes survive server destruction and snapshot independently. - Cloudflare in front of Vercel must stay "DNS only". When the Next.js storefront (or a Vercel-hosted admin) deploys to Vercel and DNS is on Cloudflare, those records must be DNS only (grey cloud), not proxied. Vercel terminates TLS and runs its own edge — CDN, image optimization, ISR, PPR — so adding the Cloudflare proxy on top breaks the TLS handshake and double-caches/conflicts with Vercel's edge features. Cloudflare's WAF/cache/analytics/Turnstile belong on the Hetzner-served records (
api.*,admin.*) where the orange cloud stays on. If you want Cloudflare features in front of Vercel anyway, that's an advanced setup ("Full (strict)" SSL + custom hostnames) that Vercel does not officially recommend.