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
- GitLab CI builds the Docker image and pushes to
registry.membership-one.com - CI SSHs into the server
docker compose pulldownloads the new imagedocker compose up -d --no-depsrestarts only the app container (DB, Redis, RMQ stay running)- Spring Boot starts, Flyway runs any new migrations automatically
- The health check endpoint
/api/actuator/healthmust returnUP - 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
developbranch - 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
mainbranch - 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:
- 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 ```
-
Deploy Previous Version: Roll back to the app version matching the restored schema
-
Verify Integrity:
bash curl -sf https://app.membership-one.com/api/actuator/health curl -sf https://app.membership-one.com/api/actuator/flyway -
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/healthreturnsUP -
/api/actuator/flywayshows 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 |