Deployment Guide — Membership One

1. Overview

Membership One runs on a single Hetzner AX62 dedicated server with all services orchestrated via Docker Compose. The platform consists of a Spring Boot 4.0.2 backend API and a Flutter web/mobile frontend, deployed across three isolated environments.

For initial server setup, see infra/README.md. For day-to-day operations, see the Operations Runbook.

Architecture

                        ┌─────────────────────────┐
                        │     Cloudflare (DNS)     │
                        └───────────┬─────────────┘
                                    │
                    ┌───────────────▼───────────────┐
                    │   Hetzner AX62 Dedicated       │
                    │   (128 GB RAM, 2x 1 TB NVMe)  │
                    │                                 │
                    │   ┌──────────────────────┐     │
                    │   │  Traefik (port 443)   │     │
                    │   └──────────┬───────────┘     │
                    │              │                   │
                    │   ┌──────────▼───────────┐     │
                    │   │  Docker Compose       │     │
                    │   │  ├─ Management        │     │
                    │   │  ├─ Monitoring         │     │
                    │   │  ├─ Production         │     │
                    │   │  ├─ Test               │     │
                    │   │  └─ Integration        │     │
                    │   └──────────────────────┘     │
                    └─────────────────────────────────┘

Environments

Environment Purpose URL Deploy Trigger
Integration Automated testing, developer preview integration.membership-one.com Auto on develop push
Test Manual testing with personas test.membership-one.com Manual gate on release/*
Production Live system app.membership-one.com Manual gate on main merge

2. Prerequisites

  • SSH access to the Hetzner server
  • Docker Engine 27+ and Docker Compose v2 (installed by bootstrap.sh)
  • GitLab account with repository access (gitlab.membership-one.com)
  • JDK 25 (Temurin) for local builds
  • Flutter 3.41.2 for frontend builds

3. Local Development

Start Infrastructure

cd backend
docker compose up -d
# PostgreSQL 18 :5432, Redis 7 :6379, RabbitMQ 4 :5672/15672, MinIO :9000/9001

Build and Run Backend

cd backend
./mvnw clean install -DskipTests
./mvnw -pl membership-runner -am spring-boot:run
# API: http://localhost:8081/api
# Swagger: http://localhost:8081/api/swagger-ui.html
# Actuator: http://localhost:8081/api/actuator/health

Build and Run Frontend

cd frontend/membership_app
flutter pub get
flutter run -d chrome       # Web (Admin)
flutter run                 # Mobile (Consumer)

Run Tests

# Backend
cd backend && ./mvnw test                   # Unit tests
cd backend && ./mvnw verify                 # With JaCoCo coverage

# Frontend
cd frontend/membership_app && flutter test  # Widget + unit tests

4. CI/CD Pipeline

Branch Strategy

Branch Purpose Deploy Target
main Production-ready code Production (manual gate)
develop Integration branch Integration (auto)
release/X.Y.Z Release preparation Test (manual gate)
feature/* Feature development None (CI only: build + test)
hotfix/* Urgent production fixes Integration → Test → Production

Pipeline Stages (.gitlab-ci.yml)

Stage Jobs Trigger
build Maven compile, Flutter build web Every push
test Unit tests, JaCoCo (> 80%), Flutter tests Every push
quality SonarQube scan, Trivy image scan, OWASP dependency-check Every push
package Build + push Docker images to GitLab Registry main, develop, release/*
deploy-int Deploy to Integration Auto on develop push
deploy-test Deploy to Test Manual gate on release/*
deploy-prod Deploy to Production Manual gate on main

Docker Image Tagging

Branch Image Tag Example
develop develop registry.membership-one.com/membership-one/backend:develop
release/1.2.0 release-1.2.0 registry.membership-one.com/membership-one/backend:release-1.2.0
main latest + v1.2.0 registry.membership-one.com/membership-one/backend:latest

5. Deployment Mechanism

The GitLab CI/CD runner SSHs into the Hetzner server and runs Docker Compose commands. There is no Kubernetes, Helm, or kubectl involved.

How CI/CD Deploys

# 1. CI runner connects to the server via SSH
ssh deploy@membership-one.com

# 2. Pull the new image
cd /opt/membership-one
docker compose -f docker-compose.${ENV}.yml pull

# 3. Restart the app container with the new image
docker compose -f docker-compose.${ENV}.yml up -d --no-deps app-${ENV}

# 4. Wait for health check to pass
docker compose -f docker-compose.${ENV}.yml ps

What Happens on Deploy

  1. GitLab CI builds the Docker image and pushes to registry.membership-one.com
  2. CI SSHs into the server
  3. docker compose pull downloads the new image
  4. docker compose up -d --no-deps restarts only the app container (DB, Redis, RMQ stay running)
  5. Spring Boot starts, Flyway runs any new migrations automatically
  6. The health check endpoint /api/actuator/health must return UP
  7. Traefik automatically routes traffic to the healthy container

Environment-Specific Compose Files

Environment Compose File App Container DB Container App Port
Integration docker-compose.integration.yml app-integration int-db 8083
Test docker-compose.test.yml app-test test-db 8082
Production docker-compose.production.yml app-production prod-db 8081

6. Manual Deployment

For deploying outside of CI/CD (e.g., hotfix, initial setup):

# SSH into the server
ssh deploy@membership-one.com
cd /opt/membership-one

# Pull latest image for the target environment
docker compose -f docker-compose.production.yml pull app-production

# Deploy with zero-downtime (only restarts app, not DB/Redis/RMQ)
docker compose -f docker-compose.production.yml up -d --no-deps app-production

# Verify health
docker compose -f docker-compose.production.yml ps
curl -sf https://app.membership-one.com/api/actuator/health

7. Environment Promotion

Code flows through environments in order: Integration → Test → Production.

Integration (automatic)

  • Trigger: Push to develop branch
  • Data: Reset on each deploy (Flyway clean + migrate)
  • Purpose: Verify builds compile, tests pass, migrations work

Test (manual gate)

  • Trigger: Manual deployment from release/* branch
  • Data: Persistent test data (V9999__test_data.sql with 5 orgs, 11 persona users, 40 members)
  • Purpose: Manual testing with personas, acceptance testing

Production (manual gate)

  • Trigger: Manual deployment from main branch
  • Data: Real customer data (persistent, never reset)
  • Purpose: Live system

Promotion Workflow

feature/xyz → develop → (auto) Integration
                ↓
          release/1.2.0 → (manual) Test
                ↓
              main → (manual) Production

8. Database Migrations (Flyway)

Flyway runs automatically on application startup. Migrations are forward-only.

Migration File Convention

V{NNN}__{description}.sql
# Examples:
# V001__create_organization.sql
# V200__create_bank_account.sql
# V9999__test_data.sql (test environment only)

Checking Migration Status

# Via Actuator endpoint
curl -sf https://app.membership-one.com/api/actuator/flyway | python3 -m json.tool

# Via Docker exec
docker exec prod-db psql -U membership -d membership_prod \
  -c "SELECT version, description, installed_on FROM flyway_schema_history ORDER BY installed_rank;"

Migration Best Practices

  • Migrations must be backward-compatible — the previous app version must work with the new schema
  • Never modify an existing migration file (Flyway checksums will fail)
  • For destructive changes (column drop), use a two-step process: 1. Deploy new version that doesn't use the column 2. Next release: add migration to drop the column

9. Rollback

Application Rollback

Roll back to the previous Docker image:

ssh deploy@membership-one.com
cd /opt/membership-one

# Find the previous image digest
docker images registry.membership-one.com/membership-one/backend --digests

# Edit compose file or override with specific tag
docker compose -f docker-compose.production.yml up -d --no-deps app-production

# Or use a specific image tag
docker run -d --name app-production-rollback \
  registry.membership-one.com/membership-one/backend:v1.1.0

Quick Rollback via GitLab

# Find the previous working pipeline
# GitLab > CI/CD > Pipelines > click "Deploy" on the previous passing pipeline

# Or revert the merge commit on main and re-deploy
git revert <merge-commit-sha>
git push origin main
# → Trigger manual production deploy

Database Rollback

Flyway migrations are forward-only. For critical database issues:

  1. Restore from Backup: Use the latest Restic backup ```bash # List available snapshots restic -r sftp:storage-box:/backups snapshots

# Restore PostgreSQL dump restic -r sftp:storage-box:/backups restore latest --target /tmp/restore --include "dumps/prod-db"

# Import into fresh database docker exec -i prod-db psql -U membership -d membership_prod < /tmp/restore/dumps/prod-db.sql ```

  1. Deploy Previous Version: Roll back to the app version matching the restored schema

  2. Verify Integrity: bash curl -sf https://app.membership-one.com/api/actuator/health curl -sf https://app.membership-one.com/api/actuator/flyway

  3. Document: Create incident report with root cause analysis

For full disaster recovery procedures, see the Operations Runbook.


10. Docker Build

Backend Image

cd backend
docker build -t registry.membership-one.com/membership-one/backend:latest \
  -f ../docker/Dockerfile .

# Test locally
docker run -p 8081:8081 --env-file .env \
  registry.membership-one.com/membership-one/backend:latest

Frontend Image

cd frontend/membership_app
flutter build web --release
cd ../..
docker build -t registry.membership-one.com/membership-one/web:latest \
  -f docker/Dockerfile.frontend .

Push to Registry

docker login registry.membership-one.com
docker push registry.membership-one.com/membership-one/backend:latest
docker push registry.membership-one.com/membership-one/web:latest

Container Registry Retention

GitLab Container Registry retention policy: - Keep: 10 most recent tagged images per branch - Cleanup: Images older than 30 days on non-default branches - Never delete: Tags matching v* (release versions)


11. Monitoring

All monitoring runs on the same Hetzner AX62 server via Docker Compose.

Component Tool URL
Metrics Prometheus Internal (port 9090)
Dashboards Grafana grafana.membership-one.com
Logs Loki + Promtail Internal (via Grafana)
Uptime Uptime Kuma status.membership-one.com

Key Alerts

Alert Condition Severity
App container down Container not running for 2 min P1
API latency high p95 > 2s for 5 min P2
DB connections exhausted > 90% pool for 5 min P1
Disk > 80% Threshold crossed P2
Certificate expiry < 14 days remaining P2
Memory pressure Container memory > 85% for 10 min P2
Container OOMKilled Any container killed by OOM P1
Backup failed backup.sh exit non-zero P1
Error rate spike 5xx > 1% for 5 min P1

Alert channels: Email (all team), Telegram Bot (P1/P2).


12. Backup and Disaster Recovery

Backup Schedule

Component Method Frequency Retention
PostgreSQL (all 5 DBs) pg_dump via backup.sh Daily 02:00 CET 7 daily, 4 weekly, 3 monthly
GitLab data Restic backup of Docker volume Daily 02:00 CET 7 daily, 4 weekly, 3 monthly
Keycloak DB pg_dump via backup.sh Daily 02:00 CET 7 daily, 4 weekly, 3 monthly
MinIO documents Restic backup of Docker volume Daily 02:00 CET 7 daily, 4 weekly, 3 monthly
Docker Compose config Restic backup of /opt/membership-one Daily 02:00 CET Git + backup
Developer home dirs Restic backup of /home Daily 02:00 CET 7 daily, 4 weekly

Backup target: Hetzner Storage Box BX11 (1 TB, sftp) via Restic (encrypted, deduplicated, incremental).

DR Targets

Metric Target
RTO (Recovery Time Objective) 4 hours
RPO (Recovery Point Objective) 24 hours (daily backups)
MTTR (Mean Time To Repair) 2 hours

For full restore procedures, see infra/scripts/restore.sh and the Operations Runbook.


13. Post-Deployment Checklist

After every production deployment:

  • /api/actuator/health returns UP
  • /api/actuator/flyway shows all migrations applied
  • Grafana dashboards show no error rate spike
  • Prometheus scrape target is healthy
  • Login flow works end-to-end
  • Cash360 webhook connectivity verified
  • TLS certificate valid (check via browser or openssl s_client)
  • Loki log aggregation active
  • Quick smoke test (create member, view dashboard)

14. Environment Variables

Variable Description Required
DB_URL / SPRING_DATASOURCE_URL PostgreSQL host / JDBC URL Yes
DB_PASSWORD / SPRING_DATASOURCE_PASSWORD Database password Yes
REDIS_HOST / SPRING_DATA_REDIS_HOST Redis host Yes
RMQ_HOST / SPRING_RABBITMQ_HOST RabbitMQ host Yes
RMQ_USER / RMQ_PASS RabbitMQ credentials Yes
JWT_SECRET JWT signing secret Yes
CASH360_API_URL Cash360 / My Factura API base URL Yes
CASH360_API_KEY Cash360 API key Yes
MINIO_ENDPOINT MinIO / S3 endpoint Yes
MINIO_ACCESS_KEY / MINIO_SECRET_KEY MinIO credentials Yes
SPRING_PROFILES_ACTIVE production, test, or integration Yes
SERVER_PORT Application port (8081/8082/8083) No
JAVA_OPTS JVM flags No

All secrets are stored in /opt/membership-one/.env on the server (permissions: 600). Generated by scripts/generate-env.sh.


15. Monthly Infrastructure Cost

Item EUR/month
Hetzner AX62 dedicated server ~79.00
Hetzner Storage Box BX11 (backups) ~3.89
Cloudflare (DNS + CDN, free plan) 0.00
Let's Encrypt (TLS, free) 0.00
Total ~82.89