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
| Env | Web | API | DB | Auth |
|---|---|---|---|---|
| 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
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
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:
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:
| Category | Keys |
|---|---|
| Database | DATABASE_URL, DATABASE_USERNAME, DATABASE_PASSWORD, HIKARI_MAX_POOL_SIZE (prod 20, dev 15), HIKARI_MIN_IDLE |
| Auth | AUTH_ISSUER_URI, AUTH_JWK_SET_URI, AUTH_SERVER_URL, AUTH_REALM, AUTH_CLIENT_ID, AUTH_CLIENT_SECRET, AUTH_ADMIN_USERNAME, AUTH_ADMIN_PASSWORD |
| JWT | JWT_SECRET (256+ bits) |
| CORS | CORS_ALLOWED_ORIGINS (comma-separated) |
| PayHere | PAYHERE_MERCHANT_ID, _MERCHANT_SECRET, _APP_ID, _APP_SECRET, _BASE_URL, _NOTIFY_URL, _RETURN_URL, _CANCEL_URL |
EMAIL_ENABLED, SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD | |
| Storage (R2) | CF_R2_ACCOUNT_ID, _ACCESS_KEY, _SECRET_KEY, _BUCKET, _PUBLIC_URL |
| Crypto | PII_ENCRYPTION_KEY, PII_HASH_KEY, NEXTAUTH_SECRET |
| Bots | RECAPTCHA_ENABLED, _SITE_KEY, _SECRET_KEY, _THRESHOLD (0.5) |
| Frontend analytics | GA_MEASUREMENT_ID, FB_PIXEL_ID |
| URLs | DOMAIN, APP_URL, API_URL, NEXT_PUBLIC_SITE_URL, API_URL_INTERNAL |
| Rate limit | RATE_LIMIT_ENABLED, TRUST_PROXY_HEADERS |
| Logging | LOG_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.
./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
- Identify the previous release SHA on the VPS:
ls /opt/cplk/releases. - Re-point the symlink:
ln -sfn /opt/cplk/releases/<old-sha> /opt/cplk/current. sudo systemctl restart cplk-api— old JAR boots in seconds.- If a migration ran with the new release, write a corrective forward migration; never alter applied Flyway files.