Platform · Hetzner · Cloudflare · Nginx

Deployment & infrastructure.

How CPLK gets from a developer's branch to webdev.cplk.org and production. Three environments, two VPSes, one Makefile, no GitHub Actions for deploys — everything is explicit and reproducible.

Environments

EnvWebAPIDBAuth
local pnpm dev · http://localhost:3000 ./gradlew bootRun · http://localhost:8080/api docker-compose Postgres Keycloak (docker-compose, port 8090)
dev / uat webdev.cplk.org · PM2 on API VPS Spring Boot via systemd on Hetzner VPS 194.146.13.121 PostgreSQL on separate Hetzner VPS 158.220.105.89 (5432 open only from API IP) auth.cplk.org · Keycloak realm cplk
prod Cloudflare Pages Spring Boot via systemd on Hetzner VPS PostgreSQL on separate VPS same Keycloak (multi-env realm)

Production topology

flowchart LR classDef users fill:#fff7ed,stroke:#f97316,color:#0f172a classDef edge fill:#ecfeff,stroke:#0891b2,color:#0f172a classDef vps fill:#eef2ff,stroke:#1e3a5f,color:#0f172a classDef ext fill:#f5f5f5,stroke:#64748b,color:#0f172a U["Internet
users"]:::users subgraph EDGE["Edge"] CF["Cloudflare Pages
Next.js standalone"]:::edge NG["Nginx · TLS · HSTS
API VPS"]:::edge end subgraph APIVPS["API VPS · Hetzner"] direction TB SYS["systemd · cplk-api.service
Java 21 · G1GC · port 8080"]:::vps WEBPM["PM2 · Next.js (dev/uat)"]:::vps KC["Keycloak (auth.cplk.org)"]:::vps end subgraph DBVPS["DB VPS · Hetzner"] direction TB PG[("PostgreSQL 16
port 5432 · API IP only")]:::vps end subgraph EXT["External"] direction TB R2[("Cloudflare R2")]:::ext PH["PayHere"]:::ext MAIL["SMTP"]:::ext end U --> CF --> NG U --> NG NG --> SYS NG --> WEBPM NG --> KC SYS --> PG SYS --> KC SYS --> R2 SYS --> PH PH -. webhook .-> SYS SYS --> MAIL
Prod web is served by Cloudflare Pages; dev/uat web runs PM2 on the API VPS alongside the API itself. The database lives on its own VPS for blast-radius isolation; Postgres only accepts connections from the API VPS IP.

Makefile cheatsheet

Every deploy action is a Make target. env= selects the environment (dev, uat, prod). SSH keys are required — no passwords.

# Deploy
make api-deploy env=dev          # build JAR, upload, restart systemd
make web-deploy env=dev          # build standalone, ship to PM2 (or Pages for prod)

# Service mgmt (via SSH systemctl)
make api-logs    env=dev
make api-restart env=dev
make api-status  env=dev

# Database (DB VPS)
make db-shell    env=dev
make db-backup   env=dev
make db-restore  env=dev FILE=...

API deploy flow

One command, fully scripted in deploy/scripts/api-deploy.sh:

sequenceDiagram autonumber participant DEV as Developer laptop participant LOCAL as Gradle (local) participant SSH as SSH (key auth) participant VPS as Hetzner API VPS participant SD as systemd DEV->>LOCAL: ./gradlew clean bootJar LOCAL-->>DEV: app.jar + .env.<env> generated DEV->>SSH: scp app.jar to /opt/cplk/releases/<sha>/ DEV->>SSH: scp .env to /etc/cplk/api.env DEV->>SSH: ln -sfn /opt/cplk/releases/<sha> /opt/cplk/current DEV->>SSH: sudo systemctl restart cplk-api.service SSH->>VPS: systemd starts new JVM (G1GC, 512m–2g) VPS->>SD: ExecStartPost → curl /actuator/health SD-->>DEV: 200 OK → deploy success

systemd unit

# /etc/systemd/system/cplk-api.service (deploy/systemd/cplk-api.service)
[Unit]
Description=CPLK API
After=network.target

[Service]
Type=simple
User=cplk
Group=cplk
EnvironmentFile=/etc/cplk/api.env
ExecStart=/usr/bin/java -XX:+UseG1GC \
  -Xms512m -Xmx2048m \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/opt/cplk/logs/heapdump.hprof \
  -jar /opt/cplk/current/app.jar

Restart=on-failure
StartLimitBurst=3
StartLimitIntervalSec=60
RestartSec=10

# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/cplk

StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Nginx

Single config deploy/nginx/cplk.conf; terminates TLS, applies headers, proxies to PM2 and the API:

server {
  listen 443 ssl http2;
  server_name webdev.cplk.org;

  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
  add_header X-Frame-Options SAMEORIGIN always;
  add_header X-Content-Type-Options nosniff always;
  gzip on; gzip_comp_level 6;
  client_max_body_size 20M;

  location /api/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header Host $host;
  }

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
  }
}

Web deploy

dev / uat · PM2

cd frontend
pnpm install --frozen-lockfile
pnpm build              # next build (standalone)
# rsync .next/standalone + .next/static + public to VPS
pm2 reload cplk-web --update-env

prod · Cloudflare Pages

cd frontend
pnpm install --frozen-lockfile
pnpm exec opennextjs-cloudflare build
pnpm exec wrangler pages deploy .open-next/dist \
  --project-name $CF_PAGES_PROJECT_NAME

Environment variables

Source files .env.dev, .env.uat, .env.prod (all gitignored). Categories:

CategoryKeys
DatabaseDATABASE_URL, DATABASE_USERNAME, DATABASE_PASSWORD, HIKARI_MAX_POOL_SIZE (prod 20, dev 15), HIKARI_MIN_IDLE
AuthAUTH_ISSUER_URI, AUTH_JWK_SET_URI, AUTH_SERVER_URL, AUTH_REALM, AUTH_CLIENT_ID, AUTH_CLIENT_SECRET, AUTH_ADMIN_USERNAME, AUTH_ADMIN_PASSWORD
JWTJWT_SECRET (256+ bits)
CORSCORS_ALLOWED_ORIGINS (comma-separated)
PayHerePAYHERE_MERCHANT_ID, _MERCHANT_SECRET, _APP_ID, _APP_SECRET, _BASE_URL, _NOTIFY_URL, _RETURN_URL, _CANCEL_URL
EmailEMAIL_ENABLED, SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD
Storage (R2)CF_R2_ACCOUNT_ID, _ACCESS_KEY, _SECRET_KEY, _BUCKET, _PUBLIC_URL
CryptoPII_ENCRYPTION_KEY, PII_HASH_KEY, NEXTAUTH_SECRET
BotsRECAPTCHA_ENABLED, _SITE_KEY, _SECRET_KEY, _THRESHOLD (0.5)
Frontend analyticsGA_MEASUREMENT_ID, FB_PIXEL_ID
URLsDOMAIN, APP_URL, API_URL, NEXT_PUBLIC_SITE_URL, API_URL_INTERNAL
Rate limitRATE_LIMIT_ENABLED, TRUST_PROXY_HEADERS
LoggingLOG_LEVEL_ROOT, LOG_LEVEL_APP, LOG_LEVEL_HIBERNATE, LOG_LEVEL_HIKARI
Cloudflare (prod)CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CF_PAGES_PROJECT_NAME
Messaging (optional)RABBITMQ_ENABLED, RABBITMQ_HOST etc.; SSE_REDIS_ENABLED, REDIS_HOST

Local docker-compose

The repo ships a docker-compose.yml with Postgres + Keycloak for local dev:

docker compose up -d           # start postgres + keycloak
cd backend && ./gradlew bootRun --args='--spring.profiles.active=local'
cd frontend && pnpm dev

Default ports: API 8080, web 3000, Postgres 5432, Keycloak 8090 (internal URL http://localhost:8090; JWT issuer is the public URL https://auth.cplk.org/realms/cplk).

CI/CD

GitHub Actions are limited to one workflow today (claude.yml for Claude Code mentions). There is no CI-driven deploy; promotions happen via a developer's make command. Build/test gates exist but they run locally and in pre-merge review.

Future direction. Add a workflow that runs ./gradlew check on PR and ./gradlew bootJar + Playwright on push to dev, uploading the artefact for promotion. Keep the actual scp + restart step in Make so humans authorise it explicitly.

Rollback

  1. Identify the previous release SHA on the VPS: ls /opt/cplk/releases.
  2. Re-point the symlink: ln -sfn /opt/cplk/releases/<old-sha> /opt/cplk/current.
  3. sudo systemctl restart cplk-api — old JAR boots in seconds.
  4. If a migration ran with the new release, write a corrective forward migration; never alter applied Flyway files.