diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..2a4a7df --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,27 @@ +# Production Environment Variables +# Copy to .env and fill in all values + +# Domain Configuration +ROOT_DOMAIN=example.com +ACME_EMAIL=admin@example.com + +# Database +POSTGRES_USER=headquarter +POSTGRES_PASSWORD=change-me-in-production +POSTGRES_DB=headquarter + +# Authentik OIDC +AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/headquarter/ +AUTHENTIK_CLIENT_ID=headquarter-api +AUTHENTIK_CLIENT_SECRET=change-me-in-production + +# API Configuration +SECRET_ENCRYPTION_KEY=change-me-in-production +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# Deployment +TRAEFIK_LOG_LEVEL=INFO +API_TAG=latest +WEB_TAG=latest +REGISTRY=ghcr.io +REPO=headquarter diff --git a/deploy/portainer-stack.yml b/deploy/portainer-stack.yml new file mode 100644 index 0000000..dbe2743 --- /dev/null +++ b/deploy/portainer-stack.yml @@ -0,0 +1,118 @@ +version: "3.8" + +services: + traefik: + image: traefik:v3.1 + command: + - --api.dashboard=true + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.letsencrypt.acme.tlschallenge=true + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --log.level=INFO + - --accesslog=true + - --accesslog.format=json + - --metrics.prometheus=true + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik-certs:/letsencrypt + networks: + - platform + - tools + labels: + - traefik.enable=true + - traefik.http.routers.traefik-dashboard.rule=Host(`traefik.${ROOT_DOMAIN}`) + - traefik.http.routers.traefik-dashboard.entrypoints=websecure + - traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt + - traefik.http.routers.traefik-dashboard.service=api@internal + - traefik.http.routers.traefik-dashboard.middlewares=auth@file + restart: unless-stopped + healthcheck: + test: ["CMD", "traefik", "healthcheck"] + interval: 10s + timeout: 5s + retries: 3 + + api: + image: ${REGISTRY:-ghcr.io}/${REPO:-headquarter}/api:${API_TAG:-latest} + env_file: + - stack.env + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + - ROOT_DOMAIN=${ROOT_DOMAIN} + - CORS_ORIGINS=https://${ROOT_DOMAIN},https://*.${ROOT_DOMAIN} + depends_on: + db: + condition: service_healthy + networks: + - platform + volumes: + - api-data:/data + labels: + - traefik.enable=true + - traefik.http.routers.api.rule=Host(`api.${ROOT_DOMAIN}`) + - traefik.http.routers.api.entrypoints=websecure + - traefik.http.routers.api.tls.certresolver=letsencrypt + - traefik.http.services.api.loadbalancer.server.port=8000 + - traefik.http.routers.api.middlewares=sec-headers@file + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + + web: + image: ${REGISTRY:-ghcr.io}/${REPO:-headquarter}/web:${WEB_TAG:-latest} + env_file: + - stack.env + depends_on: + - api + networks: + - platform + labels: + - traefik.enable=true + - traefik.http.routers.web.rule=Host(`${ROOT_DOMAIN}`) + - traefik.http.routers.web.entrypoints=websecure + - traefik.http.routers.web.tls.certresolver=letsencrypt + - traefik.http.services.web.loadbalancer.server.port=8080 + - traefik.http.routers.web.middlewares=sec-headers@file + restart: unless-stopped + + db: + image: postgres:17-alpine + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - platform + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + postgres-data: + api-data: + traefik-certs: + +networks: + platform: + driver: bridge + tools: + driver: bridge + external: false diff --git a/deploy/portainer.env.example b/deploy/portainer.env.example index 02072fd..c0041f4 100644 --- a/deploy/portainer.env.example +++ b/deploy/portainer.env.example @@ -1,27 +1,26 @@ -# Portainer stack environment variables (no secrets committed) -# Copy and configure in Portainer UI or your secrets manager. - APP_NAME=Headquarter ROOT_DOMAIN=example.com TOOL_DOMAIN=tools.example.com API_URL=https://api.example.com WEB_URL=https://example.com -# Database POSTGRES_USER=headquarter POSTGRES_DB=headquarter POSTGRES_PASSWORD= -# Authentik OIDC AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/headquarter/ AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= -# Traefik TRAEFIK_NETWORK=traefik TRAEFIK_ENTRYPOINT=websecure TRAEFIK_CERT_RESOLVER=letsencrypt TOOL_SUBDOMAIN_PATTERN={tool}-{project}-{user}.tools.{ROOT_DOMAIN} -# Secrets SECRET_ENCRYPTION_KEY= + +ACME_EMAIL=admin@example.com +API_TAG=latest +WEB_TAG=latest +REGISTRY=ghcr.io +REPO=headquarter diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..4a12439 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,117 @@ +services: + traefik: + image: traefik:v3.1 + command: + - --api.dashboard=true + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.letsencrypt.acme.tlschallenge=true + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-admin@example.com} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --log.level=${TRAEFIK_LOG_LEVEL:-INFO} + - --accesslog=true + - --accesslog.format=json + - --metrics.prometheus=true + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik-certs:/letsencrypt + networks: + - platform + - tools + labels: + - traefik.enable=true + - traefik.http.routers.traefik-dashboard.rule=Host(`traefik.${ROOT_DOMAIN:-localhost}`) + - traefik.http.routers.traefik-dashboard.entrypoints=websecure + - traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt + - traefik.http.routers.traefik-dashboard.service=api@internal + - traefik.http.routers.traefik-dashboard.middlewares=auth@file + restart: unless-stopped + healthcheck: + test: ["CMD", "traefik", "healthcheck"] + interval: 10s + timeout: 5s + retries: 3 + + api: + build: + context: ./apps/api + dockerfile: Dockerfile + env_file: + - .env + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-headquarter} + - ROOT_DOMAIN=${ROOT_DOMAIN:-localhost} + depends_on: + db: + condition: service_healthy + networks: + - platform + volumes: + - api-data:/data + labels: + - traefik.enable=true + - traefik.http.routers.api.rule=Host(`api.${ROOT_DOMAIN:-localhost}`) + - traefik.http.routers.api.entrypoints=websecure + - traefik.http.routers.api.tls.certresolver=letsencrypt + - traefik.http.services.api.loadbalancer.server.port=8000 + - traefik.http.routers.api.middlewares=sec-headers@file + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + + web: + build: + context: ./apps/web + dockerfile: Dockerfile + env_file: + - apps/web/.env + depends_on: + - api + networks: + - platform + labels: + - traefik.enable=true + - traefik.http.routers.web.rule=Host(`${ROOT_DOMAIN:-localhost}`) + - traefik.http.routers.web.entrypoints=websecure + - traefik.http.routers.web.tls.certresolver=letsencrypt + - traefik.http.services.web.loadbalancer.server.port=8080 + - traefik.http.routers.web.middlewares=sec-headers@file + restart: unless-stopped + + db: + image: postgres:17-alpine + environment: + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_DB=${POSTGRES_DB:-headquarter} + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - platform + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-headquarter}"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + postgres-data: + api-data: + traefik-certs: + +networks: + platform: + driver: bridge + tools: + driver: bridge + external: false