- 01-PROJECT-CHARTER.md: project charter with Gantt chart and risk register - 02-USER-STORIES.md: 25 user stories with MoSCoW priorities and ACs - 03-ARCHITECTURE.md: system architecture, ERD (8 entities), multi-tenancy design - 04-FLOWCHARTS.md: 5 business logic flow charts (distribution, recall, etc) - 05-API-SPEC.md: REST API spec (7 controllers, 30+ endpoints) - 06-WIREFRAMES.md: 6 screen wireframes with AI-generated mockup images - 07-CODING-STANDARDS.md: Java 21 standards, Git strategy, compliance rules - 08-TEST-PLAN.md: 26 test cases, JaCoCo coverage gates - 09-DEPLOYMENT-GUIDE.md: Hetzner Docker Compose + Gitea CI/CD pipeline - README.md + CHANGELOG.md + 10-RETROSPECTIVE.md - 5 AI-generated UI mockup images (Flux Schnell/ComfyUI)
16 KiB
09 — Deployment Guide
Project: CannaManage — B2B SaaS for German Cannabis Social Clubs
Version: 0.1.0-PLAN
Date: 2026-04-06
Target environment: Hetzner VPS — Ubuntu 22.04 LTS — Docker Compose
1. Prerequisites
Hetzner VPS Specification
| Resource | Value | Monthly Cost |
|---|---|---|
| Server type | CX21 | ~€5.88/month |
| vCPU | 2 | — |
| RAM | 4 GB | — |
| SSD | 40 GB | — |
| Network | 20 TB transfer | — |
| OS | Ubuntu 22.04 LTS | — |
Scale-up trigger: Upgrade to CX31 (8GB RAM) when concurrent active clubs exceeds 20. PostgreSQL is the memory consumer — headroom is consumed by connection pools, not application heap.
DNS Setup
| Record | Type | Value |
|---|---|---|
cannamanage.de |
A | <VPS-IP> |
app.cannamanage.de |
A | <VPS-IP> |
*.cannamanage.de |
A | <VPS-IP> |
Wildcard A record enables future per-club subdomains (clubname.cannamanage.de) without additional DNS changes.
Required Software
- Docker Engine 24+ (
docker.ioor Docker CE) - Docker Compose v2 (
docker compose— notdocker-compose) - Certbot with Nginx plugin (
python3-certbot-nginx) - OpenSSH server (enabled by default on Ubuntu)
2. Infrastructure Architecture
graph TB
Internet["🌐 Internet"] -->|"port 80/443"| Nginx["Nginx (reverse proxy)"]
Nginx -->|"http://app:8080"| App["cannamanage-app\n(Spring Boot 3.x)"]
App -->|"jdbc:postgresql://db:5432"| DB["PostgreSQL 16\n(cannamanage DB)"]
LetsEncrypt["Let's Encrypt\n(certbot auto-renew)"] -.->|"TLS cert"| Nginx
Gitea["Gitea Actions\n(homelab CI)"] -->|"SSH + docker compose"| VPS["Hetzner VPS\n/opt/cannamanage"]
subgraph VPS ["Hetzner VPS — Docker network: cannamanage_net"]
Nginx
App
DB
end
All three services run on an internal Docker bridge network (cannamanage_net). Only Nginx is exposed to the public internet. PostgreSQL has no external port binding.
3. Docker Compose Setup
File: /opt/cannamanage/docker-compose.yml
version: '3.9'
networks:
cannamanage_net:
driver: bridge
volumes:
pgdata:
driver: local
nginx_certs:
driver: local
services:
nginx:
image: nginx:1.25-alpine
container_name: cannamanage-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- nginx_certs:/etc/letsencrypt:ro
- /var/log/nginx:/var/log/nginx
depends_on:
app:
condition: service_healthy
networks:
- cannamanage_net
restart: unless-stopped
app:
image: cannamanage:${VERSION:-latest}
container_name: cannamanage-app
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/cannamanage
- SPRING_DATASOURCE_USERNAME=${DB_USERNAME}
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}
- APP_JWT_SECRET=${JWT_SECRET}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- SPRING_MAIL_HOST=${MAIL_HOST}
- SPRING_MAIL_USERNAME=${MAIL_USERNAME}
- SPRING_MAIL_PASSWORD=${MAIL_PASSWORD}
- SENTRY_DSN=${SENTRY_DSN}
- SPRING_PROFILES_ACTIVE=production
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- cannamanage_net
restart: unless-stopped
db:
image: postgres:16-alpine
container_name: cannamanage-db
environment:
- POSTGRES_DB=cannamanage
- POSTGRES_USER=${DB_USERNAME}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d cannamanage"]
interval: 10s
timeout: 5s
retries: 5
networks:
- cannamanage_net
restart: unless-stopped
# PostgreSQL port intentionally NOT exposed externally
Nginx site config (/opt/cannamanage/nginx/conf.d/cannamanage.conf):
server {
listen 80;
server_name app.cannamanage.de;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app.cannamanage.de;
ssl_certificate /etc/letsencrypt/live/app.cannamanage.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.cannamanage.de/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Stripe webhook — allow larger body
location /api/v1/billing/webhook {
proxy_pass http://app:8080;
proxy_set_header Host $host;
client_max_body_size 1m;
}
}
4. Environment Variables
File: /opt/cannamanage/.env (never committed to git — add to .gitignore)
# Database
DB_USERNAME=cannamanage_user
DB_PASSWORD=<strong-random-password-min-32-chars>
# JWT signing key (256-bit minimum — generate with: openssl rand -hex 32)
JWT_SECRET=<256-bit-random-hex>
# Stripe (use sk_live_ for production, sk_test_ for staging)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Email (SMTP)
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=noreply@cannamanage.de
MAIL_PASSWORD=<mail-password>
MAIL_FROM=CannaManage <noreply@cannamanage.de>
# Error tracking
SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project-id>
# Application version (set by CI during deploy)
VERSION=latest
Security: Never store
.envin version control. Use Gitea repository secrets for CI and inject at deploy time. On the VPS, set file permissions:chmod 600 /opt/cannamanage/.env.
5. First-Time Deployment
Step 1 — Create Hetzner VPS
- Log into console.hetzner.cloud
- Create server: CX21, Ubuntu 22.04, Nuremberg or Frankfurt datacenter
- Add your SSH public key during setup (
cat ~/.ssh/id_ed25519.pub) - Note the assigned IPv4 address — update DNS A records
Step 2 — Install Docker + Docker Compose
ssh root@<VPS-IP>
# Update system
apt update && apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
# Add deploy user (never run production as root)
adduser deploy
usermod -aG docker deploy
usermod -aG sudo deploy
# Install Certbot
apt install -y python3-certbot-nginx certbot
Step 3 — Clone Repository
su - deploy
mkdir -p /opt/cannamanage
cd /opt/cannamanage
git clone http://192.168.188.119:30008/pplate/cannamanage.git .
# Or from public mirror when available
Step 4 — Create Production .env
cd /opt/cannamanage
cp .env.example .env
nano .env # Fill in all production secrets
chmod 600 .env
Step 5 — Obtain SSL Certificate
# Stop anything on port 80 first (nothing should be running yet)
certbot certonly --standalone \
-d app.cannamanage.de \
--non-interactive \
--agree-tos \
-m ssl@cannamanage.de
# Symlink certs into nginx_certs volume location
# Certbot places certs at /etc/letsencrypt/live/app.cannamanage.de/
Step 6 — Build Docker Image
# On the VPS (or build locally and push to registry)
./mvnw package -DskipTests -P production
docker build -t cannamanage:latest .
Step 7 — Start Services
cd /opt/cannamanage
docker compose up -d
Step 8 — Verify Health
# All containers should be 'healthy' or 'running'
docker compose ps
# Check application logs
docker compose logs -f app --tail=100
# Test health endpoint
curl -f http://localhost:8080/actuator/health
# Expected: {"status":"UP","components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"}}}
Step 9 — Flyway Migrations
Flyway runs automatically on Spring Boot startup. Verify migration log:
docker compose logs app | grep -i flyway
# Expected: Successfully applied N migrations to schema "public"
Step 10 — Create First Admin User
# Option A: via REST API (recommended)
curl -X POST https://app.cannamanage.de/api/v1/admin/bootstrap \
-H "Content-Type: application/json" \
-d '{
"adminEmail": "admin@yourclub.de",
"adminPassword": "<strong-password>",
"clubName": "Your Club e.V.",
"clubRegistrationNumber": "VR 12345"
}'
# The bootstrap endpoint is disabled after first use (one-time setup flag in DB)
Step 11 — Verify Production Access
# Web UI
open https://app.cannamanage.de
# API health check
curl https://app.cannamanage.de/actuator/health
6. CI/CD Pipeline (Gitea Actions)
File: .gitea/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Run unit tests
run: ./mvnw test -pl cannamanage-service
- name: Run integration tests
run: ./mvnw verify -P integration-tests
# Testcontainers requires Docker — GitHub/Gitea hosted runners have Docker pre-installed
- name: Coverage gate check
run: ./mvnw verify -P coverage-check
build-and-deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build JAR
run: ./mvnw package -DskipTests -P production
- name: Build Docker image
run: |
docker build \
-t cannamanage:${{ github.sha }} \
-t cannamanage:latest \
.
- name: Save Docker image
run: docker save cannamanage:${{ github.sha }} | gzip > /tmp/cannamanage.tar.gz
- name: Copy image to VPS
run: |
scp -o StrictHostKeyChecking=no \
/tmp/cannamanage.tar.gz \
deploy@${{ secrets.HETZNER_IP }}:/tmp/cannamanage.tar.gz
- name: Deploy via SSH
run: |
ssh -o StrictHostKeyChecking=no deploy@${{ secrets.HETZNER_IP }} "
set -e
cd /opt/cannamanage
# Load new image
docker load < /tmp/cannamanage.tar.gz
rm /tmp/cannamanage.tar.gz
# Rolling restart app only (DB stays up)
VERSION=${{ github.sha }} docker compose up -d app
# Wait for health
sleep 10
docker compose ps app | grep 'healthy' || (docker compose logs app --tail=50 && exit 1)
# Prune old images (keep last 3)
docker image prune -f
"
Required Gitea Repository Secrets
| Secret | Value |
|---|---|
HETZNER_IP |
VPS IPv4 address |
SSH_PRIVATE_KEY |
Private key for deploy user |
Add deploy user's public key to VPS authorized_keys:
# On VPS as deploy user
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "<gitea-actions-public-key>" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
7. Database Backup
Automated Daily Backup
Add to root crontab (crontab -e):
# Daily backup at 03:00 UTC — keep 14 days
0 3 * * * docker exec cannamanage-db pg_dump \
-U cannamanage_user \
--format=custom \
cannamanage | gzip > /opt/backups/cannamanage_$(date +\%Y\%m\%d).sql.gz
# Cleanup backups older than 14 days
5 3 * * * find /opt/backups -name "cannamanage_*.sql.gz" -mtime +14 -delete
Create backup directory:
mkdir -p /opt/backups
chown deploy:deploy /opt/backups
Restore from Backup
# Restore (caution: this overwrites existing data)
gunzip -c /opt/backups/cannamanage_20260406.sql.gz | \
docker exec -i cannamanage-db pg_restore \
-U cannamanage_user \
--clean \
--dbname=cannamanage
# Verify restore
docker exec cannamanage-db psql \
-U cannamanage_user \
-d cannamanage \
-c "SELECT COUNT(*) FROM clubs;"
Offsite Backup (Optional)
For additional redundancy, sync backups to Hetzner Object Storage:
# Install s3cmd and configure with Hetzner S3-compatible endpoint
s3cmd sync /opt/backups/ s3://cannamanage-backups/
8. Monitoring & Health Checks
Spring Boot Actuator
The application exposes health endpoints via spring-boot-actuator:
# Full health detail (requires ROLE_ADMIN JWT)
GET /actuator/health
# Example response
{
"status": "UP",
"components": {
"db": { "status": "UP", "details": { "database": "PostgreSQL", "validationQuery": "isValid()" } },
"diskSpace": { "status": "UP", "details": { "total": 42GB, "free": 30GB } },
"ping": { "status": "UP" }
}
}
Expose only health and info publicly in application-production.yml:
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
Log Locations
| Source | Location |
|---|---|
| Application logs | docker compose logs -f app |
| Nginx access logs | /var/log/nginx/access.log |
| Nginx error logs | /var/log/nginx/error.log |
| PostgreSQL logs | docker compose logs db |
| Sentry (errors) | https://sentry.io/organizations/<org>/ |
Alerting
Configure Sentry to email on new errors:
- Set
SENTRY_DSNin.env - Add
io.sentry:sentry-spring-boot-starter-jakarta:7.xto POM - Sentry auto-captures all unhandled exceptions with full stack trace
Simple uptime check via cron + email:
# Health check every 5 minutes — email on 3 consecutive failures
*/5 * * * * /opt/cannamanage/scripts/health_check.sh
#!/bin/bash
# /opt/cannamanage/scripts/health_check.sh
HEALTH_URL="https://app.cannamanage.de/actuator/health"
FAIL_COUNT_FILE="/tmp/cannamanage_health_fails"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL")
if [ "$HTTP_STATUS" != "200" ]; then
FAILS=$(cat "$FAIL_COUNT_FILE" 2>/dev/null || echo 0)
FAILS=$((FAILS + 1))
echo "$FAILS" > "$FAIL_COUNT_FILE"
if [ "$FAILS" -ge 3 ]; then
echo "CannaManage health check failed $FAILS times" | \
mail -s "ALERT: CannaManage DOWN" admin@cannamanage.de
fi
else
echo 0 > "$FAIL_COUNT_FILE"
fi
9. SSL Certificate Renewal
Let's Encrypt certificates expire after 90 days. Certbot handles renewal automatically:
# Test renewal (dry run — no actual renewal)
certbot renew --dry-run
# Manual renewal
certbot renew --nginx
# Reload Nginx after renewal
docker exec cannamanage-nginx nginx -s reload
Auto-Renewal via Cron
# Renew at 02:00 UTC on the 1st and 15th of each month
0 2 1,15 * * certbot renew --quiet --nginx && \
docker exec cannamanage-nginx nginx -s reload
Certbot only renews when the certificate is less than 30 days from expiry — safe to run frequently.
10. Rollback Procedure
If a deployment causes issues:
# On VPS — list recent images
docker images cannamanage --format "table {{.Tag}}\t{{.CreatedAt}}"
# Roll back to previous SHA
cd /opt/cannamanage
VERSION=<previous-sha> docker compose up -d app
# Verify health after rollback
docker compose ps app
curl https://app.cannamanage.de/actuator/health
If database migrations were applied and rollback is needed:
- Restore from last backup (see Section 7)
- Redeploy the previous image version
- Flyway baseline the schema at previous version
Note: Flyway migrations are append-only and forward-only. Design migrations to be reversible where possible (add columns before removing old ones, etc.).