Files
pi_mcps/docs/wiki/pages/CannaManage-09-Deployment.md
Patrick Plate cda8946c75 docs(cannamanage): add CannaManage wiki pages and mockup images
- 11 wiki pages: CannaManage-Home + 01-10 covering full Phase 0 docs
- 5 mockup images in docs/wiki/images/
- Updated _Sidebar.md with CannaManage section
2026-04-06 11:21:35 +02:00

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.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

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 .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
  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

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:

  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:

# 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:

  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.).