# 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 | `` | | `app.cannamanage.de` | A | `` | | `*.cannamanage.de` | A | `` | Wildcard A record enables future per-club subdomains (`clubname.cannamanage.de`) without additional DNS changes. ### Required Software - Docker Engine 24+ (`docker.io` or Docker CE) - Docker Compose v2 (`docker compose` — not `docker-compose`) - Certbot with Nginx plugin (`python3-certbot-nginx`) - OpenSSH server (enabled by default on Ubuntu) --- ## 2. Infrastructure Architecture ```mermaid 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` ```yaml 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`): ```nginx 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`) ```bash # Database DB_USERNAME=cannamanage_user DB_PASSWORD= # 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_FROM=CannaManage # Error tracking SENTRY_DSN=https://@.ingest.sentry.io/ # Application version (set by CI during deploy) VERSION=latest ``` > **Security:** Never store `.env` in 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 1. Log into [console.hetzner.cloud](https://console.hetzner.cloud) 2. Create server: **CX21**, Ubuntu 22.04, Nuremberg or Frankfurt datacenter 3. Add your SSH public key during setup (`cat ~/.ssh/id_ed25519.pub`) 4. Note the assigned IPv4 address — update DNS A records ### Step 2 — Install Docker + Docker Compose ```bash ssh root@ # 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 ```bash 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` ```bash cd /opt/cannamanage cp .env.example .env nano .env # Fill in all production secrets chmod 600 .env ``` ### Step 5 — Obtain SSL Certificate ```bash # 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 ```bash # On the VPS (or build locally and push to registry) ./mvnw package -DskipTests -P production docker build -t cannamanage:latest . ``` ### Step 7 — Start Services ```bash cd /opt/cannamanage docker compose up -d ``` ### Step 8 — Verify Health ```bash # 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: ```bash docker compose logs app | grep -i flyway # Expected: Successfully applied N migrations to schema "public" ``` ### Step 10 — Create First Admin User ```bash # 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": "", "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 ```bash # 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` ```yaml 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: ```bash # On VPS as deploy user mkdir -p ~/.ssh && chmod 700 ~/.ssh echo "" >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys ``` --- ## 7. Database Backup ### Automated Daily Backup Add to root crontab (`crontab -e`): ```bash # 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: ```bash mkdir -p /opt/backups chown deploy:deploy /opt/backups ``` ### Restore from Backup ```bash # 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: ```bash # 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`: ```bash # 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`: ```yaml 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//` | ### Alerting Configure Sentry to email on new errors: 1. Set `SENTRY_DSN` in `.env` 2. Add `io.sentry:sentry-spring-boot-starter-jakarta:7.x` to POM 3. Sentry auto-captures all unhandled exceptions with full stack trace Simple uptime check via `cron` + email: ```bash # Health check every 5 minutes — email on 3 consecutive failures */5 * * * * /opt/cannamanage/scripts/health_check.sh ``` ```bash #!/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: ```bash # 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 ```bash # 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: ```bash # On VPS — list recent images docker images cannamanage --format "table {{.Tag}}\t{{.CreatedAt}}" # Roll back to previous SHA cd /opt/cannamanage VERSION= 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: 1. Restore from last backup (see Section 7) 2. Redeploy the previous image version 3. 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.).