From b38902a7ee2ce7f2c0d2f8570491171354c3caf7 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Fri, 12 Jun 2026 22:11:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(sprint-6):=20Phase=201=20=E2=80=94=20Produ?= =?UTF-8?q?ction=20deployment=20infrastructure=20(IONOS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.prod.yml: production Docker Compose with health checks, logging, restart policies, resource limits - deploy/nginx/cannamanage.conf: Nginx reverse proxy with TLS, CSP, security headers, rate limiting - deploy/.env.production.example: environment template for secrets - deploy/backup.sh: GPG-encrypted daily/weekly PostgreSQL backup with retention - deploy/deploy.sh: manual deploy script with health check verification - .gitea/workflows/deploy.yml: Gitea Actions CI/CD pipeline (test + deploy) - application-production.properties: Spring Boot production profile (no stacktraces, Swagger disabled, Stripe) - .gitignore: added .env to prevent accidental secret commits --- .gitea/workflows/deploy.yml | 51 +++++++++ .gitignore | 3 + .../application-production.properties | 50 ++++++++ deploy/.env.production.example | 41 +++++++ deploy/backup.sh | 47 ++++++++ deploy/deploy.sh | 64 +++++++++++ deploy/nginx/cannamanage.conf | 107 ++++++++++++++++++ docker-compose.prod.yml | 98 ++++++++++++++++ 8 files changed, 461 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 cannamanage-api/src/main/resources/application-production.properties create mode 100644 deploy/.env.production.example create mode 100755 deploy/backup.sh create mode 100755 deploy/deploy.sh create mode 100644 deploy/nginx/cannamanage.conf create mode 100644 docker-compose.prod.yml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..a7999c1 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,51 @@ +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' + + - name: Run backend tests + run: ./mvnw verify -B -q + + deploy: + needs: test + runs-on: ubuntu-latest + steps: + - name: Deploy to production + uses: appleboy/ssh-action@v1 + with: + host: plate-software.de + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd /opt/cannamanage + git pull origin main + docker compose -f docker-compose.prod.yml build + docker compose -f docker-compose.prod.yml up -d + + # Wait for backend health + sleep 15 + for i in 1 2 3 4 5; do + if curl -sf http://127.0.0.1:8080/actuator/health > /dev/null 2>&1; then + echo "✅ Deploy successful at $(date)" + exit 0 + fi + echo "Waiting... attempt $i/5" + sleep 5 + done + + echo "❌ Deploy failed — backend unhealthy" + docker compose -f docker-compose.prod.yml logs --tail=30 backend + exit 1 diff --git a/.gitignore b/.gitignore index b244c2d..d741a92 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ target/ cannamanage-frontend/node_modules/ cannamanage-frontend/.next/ cannamanage-frontend/.env.local + +# Production secrets (never commit) +.env diff --git a/cannamanage-api/src/main/resources/application-production.properties b/cannamanage-api/src/main/resources/application-production.properties new file mode 100644 index 0000000..e182d24 --- /dev/null +++ b/cannamanage-api/src/main/resources/application-production.properties @@ -0,0 +1,50 @@ +# ============================================================================= +# Cannamanage — Production Profile +# ============================================================================= +# Activated via: SPRING_PROFILES_ACTIVE=production +# ============================================================================= + +# Database +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.hikari.maximum-pool-size=10 +spring.datasource.hikari.minimum-idle=2 + +# JPA +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false + +# Flyway +spring.flyway.enabled=true + +# JWT Security +cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET} +cannamanage.security.jwt.access-token-expiry=3600 +cannamanage.security.jwt.refresh-token-expiry=2592000 + +# Stripe +stripe.secret-key=${STRIPE_SECRET_KEY} +stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET} + +# Error handling — never expose internals +server.error.include-message=never +server.error.include-stacktrace=never +server.error.include-binding-errors=never + +# Actuator — health only, no sensitive details +management.endpoints.web.exposure.include=health +management.endpoint.health.show-details=never + +# Logging — production levels +logging.level.root=WARN +logging.level.de.cannamanage=INFO +logging.level.org.springframework.security=WARN +logging.level.org.hibernate.SQL=OFF + +# Disable Swagger in production +springdoc.api-docs.enabled=false +springdoc.swagger-ui.enabled=false + +# App base URL +app.base-url=https://cannamanage.plate-software.de diff --git a/deploy/.env.production.example b/deploy/.env.production.example new file mode 100644 index 0000000..6899bce --- /dev/null +++ b/deploy/.env.production.example @@ -0,0 +1,41 @@ +# ============================================================================= +# Cannamanage Production Environment Variables +# ============================================================================= +# Copy this file to .env in the project root on the production server: +# cp deploy/.env.production.example .env +# Then fill in all CHANGE_ME values with real secrets. +# ============================================================================= + +# --- Database --- +DB_NAME=cannamanage +DB_USER=cannamanage +DB_PASSWORD=CHANGE_ME_STRONG_PASSWORD + +# --- JWT --- +# Minimum 32 characters, random. Generate with: openssl rand -base64 48 +CANNAMANAGE_SECURITY_JWT_SECRET=CHANGE_ME_MINIMUM_32_CHARACTERS_RANDOM +JWT_SECRET=CHANGE_ME_MINIMUM_32_CHARACTERS_RANDOM + +# --- NextAuth --- +# Generate with: openssl rand -base64 32 +NEXTAUTH_SECRET=CHANGE_ME_RANDOM_32_CHARS +NEXTAUTH_URL=https://cannamanage.plate-software.de + +# --- Stripe --- +STRIPE_SECRET_KEY=sk_live_CHANGE_ME +STRIPE_WEBHOOK_SECRET=whsec_CHANGE_ME +STRIPE_PUBLISHABLE_KEY=pk_live_CHANGE_ME + +# --- Email (SMTP) --- +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=CHANGE_ME +SMTP_PASSWORD=CHANGE_ME +SMTP_AUTH=true +SMTP_STARTTLS=true +MAIL_FROM=noreply@cannamanage.de + +# --- Backup --- +BACKUP_GPG_RECIPIENT=cannamanage-backup +BACKUP_RETENTION_DAYS=7 +BACKUP_RETENTION_WEEKS=4 diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..b92b3e5 --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# ============================================================================= +# Cannamanage — Automated PostgreSQL Backup (GPG encrypted) +# ============================================================================= +# Usage: ./deploy/backup.sh +# Cron: 0 2 * * * /opt/cannamanage/deploy/backup.sh >> /var/log/cannamanage-backup.log 2>&1 +# ============================================================================= +set -euo pipefail + +# Load environment +if [ -f /opt/cannamanage/.env ]; then + set -a + source /opt/cannamanage/.env + set +a +fi + +BACKUP_DIR="/opt/cannamanage/backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +DB_CONTAINER="cannamanage-db-1" +GPG_RECIPIENT="${BACKUP_GPG_RECIPIENT:-cannamanage-backup}" +RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-7}" +RETENTION_WEEKS="${BACKUP_RETENTION_WEEKS:-4}" + +mkdir -p "$BACKUP_DIR/daily" "$BACKUP_DIR/weekly" + +echo "[$(date)] Starting backup..." + +# Dump database + compress + encrypt +docker exec "$DB_CONTAINER" pg_dump -U "${DB_USER:-cannamanage}" "${DB_NAME:-cannamanage}" | \ + gzip | \ + gpg --encrypt --recipient "$GPG_RECIPIENT" --trust-model always \ + > "$BACKUP_DIR/daily/backup_${TIMESTAMP}.sql.gz.gpg" + +BACKUP_SIZE=$(du -h "$BACKUP_DIR/daily/backup_${TIMESTAMP}.sql.gz.gpg" | cut -f1) +echo "[$(date)] Daily backup completed: backup_${TIMESTAMP}.sql.gz.gpg ($BACKUP_SIZE)" + +# Weekly backup on Sundays +if [ "$(date +%u)" -eq 7 ]; then + cp "$BACKUP_DIR/daily/backup_${TIMESTAMP}.sql.gz.gpg" "$BACKUP_DIR/weekly/" + echo "[$(date)] Weekly backup copied" +fi + +# Retention: keep N daily, M weekly +find "$BACKUP_DIR/daily" -name "*.sql.gz.gpg" -mtime +"$RETENTION_DAYS" -delete +find "$BACKUP_DIR/weekly" -name "*.sql.gz.gpg" -mtime +$((RETENTION_WEEKS * 7)) -delete + +echo "[$(date)] Backup completed successfully. Retention: ${RETENTION_DAYS}d daily, ${RETENTION_WEEKS}w weekly" diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..8298cd3 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# ============================================================================= +# Cannamanage — Manual Production Deploy Script +# ============================================================================= +# Usage: ./deploy/deploy.sh +# Run from: /opt/cannamanage on the production server +# ============================================================================= +set -euo pipefail + +DEPLOY_DIR="/opt/cannamanage" +COMPOSE_FILE="docker-compose.prod.yml" + +cd "$DEPLOY_DIR" + +echo "=== Cannamanage Deploy — $(date) ===" +echo "" + +# Pull latest code +echo "[1/5] Pulling latest code..." +git pull origin main + +# Build images +echo "[2/5] Building Docker images..." +docker compose -f "$COMPOSE_FILE" build --no-cache + +# Bring up services (rolling restart) +echo "[3/5] Starting services..." +docker compose -f "$COMPOSE_FILE" up -d + +# Wait for backend health +echo "[4/5] Waiting for health check..." +sleep 15 + +RETRIES=5 +for i in $(seq 1 $RETRIES); do + if curl -sf http://127.0.0.1:8080/actuator/health > /dev/null 2>&1; then + echo " ✅ Backend healthy" + break + fi + if [ "$i" -eq "$RETRIES" ]; then + echo " ❌ Backend health check failed after $RETRIES attempts!" + echo "" + echo "=== Recent logs ===" + docker compose -f "$COMPOSE_FILE" logs --tail=30 backend + exit 1 + fi + echo " Attempt $i/$RETRIES — waiting 5s..." + sleep 5 +done + +# Verify frontend +if curl -sf http://127.0.0.1:3000 > /dev/null 2>&1; then + echo " ✅ Frontend healthy" +else + echo " ⚠️ Frontend not responding (may still be starting)" +fi + +# Cleanup old images +echo "[5/5] Cleaning up old images..." +docker image prune -f --filter "until=168h" 2>/dev/null || true + +echo "" +echo "=== Deploy successful — $(date) ===" +echo " URL: https://cannamanage.plate-software.de" diff --git a/deploy/nginx/cannamanage.conf b/deploy/nginx/cannamanage.conf new file mode 100644 index 0000000..e9ff0ea --- /dev/null +++ b/deploy/nginx/cannamanage.conf @@ -0,0 +1,107 @@ +# Cannamanage — Nginx reverse proxy configuration +# Location: /etc/nginx/sites-available/cannamanage.conf +# Symlink: ln -s /etc/nginx/sites-available/cannamanage.conf /etc/nginx/sites-enabled/ + +# Rate limiting zone +limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s; +limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/s; + +# HTTPS server +server { + listen 443 ssl http2; + server_name cannamanage.plate-software.de; + + ssl_certificate /etc/letsencrypt/live/cannamanage.plate-software.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/cannamanage.plate-software.de/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # Security headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' js.stripe.com; frame-src js.stripe.com hooks.stripe.com; img-src 'self' data: blob: *.stripe.com; style-src 'self' 'unsafe-inline'; connect-src 'self' api.stripe.com; font-src 'self' data:;" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + # Request body size (for file uploads if needed) + client_max_body_size 10m; + + # Frontend (Next.js) + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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_cache_bypass $http_upgrade; + } + + # Backend API + location /api/v1/ { + limit_req zone=api burst=40 nodelay; + proxy_pass http://127.0.0.1:8080/api/v1/; + 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; + } + + # Auth endpoints — stricter rate limit + location /api/v1/auth/ { + limit_req zone=auth burst=10 nodelay; + proxy_pass http://127.0.0.1:8080/api/v1/auth/; + 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; + } + + # Stripe webhooks — no rate limit, Stripe handles its own retry logic + location /api/v1/webhooks/stripe { + proxy_pass http://127.0.0.1:8080/api/v1/webhooks/stripe; + 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_set_header Stripe-Signature $http_stripe_signature; + } + + # Health check endpoint (internal only) + location /health { + proxy_pass http://127.0.0.1:8080/actuator/health; + access_log off; + allow 127.0.0.1; + deny all; + } + + # Block common exploit paths + location ~ /\.(git|env|htaccess) { + deny all; + return 404; + } +} + +# HTTP → HTTPS redirect +server { + listen 80; + server_name cannamanage.plate-software.de; + + # Allow ACME challenge for cert renewal + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..84f6e4a --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,98 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + - ./scripts/seed/init.sql:/docker-entrypoint-initdb.d/01-seed.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + backend: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + SPRING_PROFILES_ACTIVE: production + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${DB_NAME} + SPRING_DATASOURCE_USERNAME: ${DB_USER} + SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD} + CANNAMANAGE_SECURITY_JWT_SECRET: ${JWT_SECRET} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} + SMTP_HOST: ${SMTP_HOST:-localhost} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_AUTH: ${SMTP_AUTH:-true} + SMTP_STARTTLS: ${SMTP_STARTTLS:-true} + MAIL_FROM: ${MAIL_FROM:-noreply@cannamanage.de} + APP_BASE_URL: https://cannamanage.plate-software.de + depends_on: + db: + condition: service_healthy + deploy: + resources: + limits: + memory: 1G + cpus: "1.0" + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + frontend: + build: + context: ./cannamanage-frontend + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "127.0.0.1:3000:3000" + environment: + NEXTAUTH_URL: https://cannamanage.plate-software.de + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + BACKEND_URL: http://backend:8080 + AUTH_URL: https://cannamanage.plate-software.de + depends_on: + backend: + condition: service_healthy + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + pgdata: