From 1087c8a8a2e63b574c23c64b66fde0c2ffa11b65 Mon Sep 17 00:00:00 2001 From: Demian Date: Fri, 24 Apr 2026 02:26:24 -0400 Subject: [PATCH 1/5] feat: add production docker compose deployment - add docker-compose.prod.yml and a production env template - add deploy and nginx config under infra for VPS rollout - enable Nest shutdown hooks for graceful container shutdown --- .env.production.example | 45 ++++++++++++++++++ backend/src/main.ts | 1 + docker-compose.prod.yml | 73 ++++++++++++++++++++++++++++++ infra/nginx/station.drdnt.org.conf | 11 ++++- infra/scripts/deploy.sh | 7 +++ 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 .env.production.example create mode 100644 docker-compose.prod.yml create mode 100755 infra/scripts/deploy.sh diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..df9f53a --- /dev/null +++ b/.env.production.example @@ -0,0 +1,45 @@ +NODE_ENV=production +PORT=3001 +APP_NAME=STATION BACKEND + +JWT_SECRET=replace-with-a-long-random-secret +ALLOWED_ORIGIN=https://station.drdnt.org +FRONTEND_URL=https://station.drdnt.org + +DATABASE_HOST=postgres +DATABASE_PORT=5432 +DATABASE_USER=stationDbUser +DATABASE_PASSWORD=replace-with-a-strong-database-password +DATABASE_NAME=stationDb + +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=replace-with-a-strong-redis-password +USE_REDIS_CACHE=true + +REFRESH_TOKEN_CLEANUP_CRON=0 3 * * * + +THROTTLE_TTL_MS=60000 +THROTTLE_LIMIT=100 +AUTH_LOGIN_THROTTLE_TTL_MS=60000 +AUTH_LOGIN_THROTTLE_LIMIT=10 +AUTH_REGISTER_THROTTLE_TTL_MS=60000 +AUTH_REGISTER_THROTTLE_LIMIT=5 +AUTH_FORGOT_THROTTLE_TTL_MS=60000 +AUTH_FORGOT_THROTTLE_LIMIT=5 + +UEX_SYNC_ENABLED=true +UEX_CATEGORIES_SYNC_ENABLED=true +UEX_ITEMS_SYNC_ENABLED=true +UEX_LOCATIONS_SYNC_ENABLED=true +UEX_API_BASE_URL=https://uexcorp.space/api/2.0 +UEX_TIMEOUT_MS=60000 +UEX_BATCH_SIZE=100 +UEX_CONCURRENT_CATEGORIES=3 +UEX_RETRY_ATTEMPTS=3 +UEX_BACKOFF_BASE_MS=1000 +UEX_RATE_LIMIT_PAUSE_MS=2000 +UEX_ENDPOINTS_PAUSE_MS=2000 +UEX_API_KEY= + +STATION_VERSION=latest diff --git a/backend/src/main.ts b/backend/src/main.ts index 1a0a583..7c8d726 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -17,6 +17,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.enableShutdownHooks(); // Application configuration const configService = app.get(ConfigService); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..1d23adb --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,73 @@ +services: + backend: + image: ghcr.io/gitaddremote/station-backend:${STATION_VERSION:-latest} + restart: unless-stopped + env_file: + - .env.production + ports: + - '127.0.0.1:3001:3001' + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:3001/health'] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s + stop_grace_period: 30s + + frontend: + image: ghcr.io/gitaddremote/station-frontend:${STATION_VERSION:-latest} + restart: unless-stopped + ports: + - '127.0.0.1:3000:80' + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:80'] + interval: 15s + timeout: 5s + retries: 3 + start_period: 15s + + postgres: + image: postgres:16-alpine + restart: unless-stopped + env_file: + - .env.production + environment: + POSTGRES_USER: ${DATABASE_USER} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + POSTGRES_DB: ${DATABASE_NAME} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${DATABASE_USER} -d ${DATABASE_NAME}', + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + redis: + image: redis:7-alpine + restart: unless-stopped + env_file: + - .env.production + command: sh -c 'redis-server --requirepass "$$REDIS_PASSWORD" --appendonly yes' + volumes: + - redis_data:/data + healthcheck: + test: ['CMD-SHELL', 'redis-cli -a "$$REDIS_PASSWORD" ping'] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + +volumes: + postgres_data: + redis_data: diff --git a/infra/nginx/station.drdnt.org.conf b/infra/nginx/station.drdnt.org.conf index 38f5c8b..86f734b 100644 --- a/infra/nginx/station.drdnt.org.conf +++ b/infra/nginx/station.drdnt.org.conf @@ -2,8 +2,17 @@ server { listen 80; server_name station.drdnt.org; + location /api/ { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + 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; + } + location / { - proxy_pass http://127.0.0.1:5173; + proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/infra/scripts/deploy.sh b/infra/scripts/deploy.sh new file mode 100755 index 0000000..79375c4 --- /dev/null +++ b/infra/scripts/deploy.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail + +cd /opt/station +docker compose --env-file .env.production -f docker-compose.prod.yml pull +docker compose --env-file .env.production -f docker-compose.prod.yml up -d --no-deps backend frontend +docker compose --env-file .env.production -f docker-compose.prod.yml ps From 4498bd7c57c595bcac2999528a3f13101adf832d Mon Sep 17 00:00:00 2001 From: Demian Date: Fri, 24 Apr 2026 02:54:50 -0400 Subject: [PATCH 2/5] test: cover production deploy config - extend infra tests for deploy.sh and the production station nginx config - keep the stacked issue #108 branch validated through pnpm --filter infra test --- infra/tests/infrastructure.test.mjs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/infra/tests/infrastructure.test.mjs b/infra/tests/infrastructure.test.mjs index 4ac0117..62462c4 100644 --- a/infra/tests/infrastructure.test.mjs +++ b/infra/tests/infrastructure.test.mjs @@ -119,6 +119,23 @@ test('cert issuance script requests all Station domains and verifies renewal', ( assert.match(script, /certbot renew --dry-run/); }); +test('deploy script uses docker compose with the production env file', () => { + const script = readInfraFile('scripts/deploy.sh'); + + assert.match( + script, + /docker compose --env-file \.env\.production -f docker-compose\.prod\.yml pull/, + ); + assert.match( + script, + /docker compose --env-file \.env\.production -f docker-compose\.prod\.yml up -d --no-deps backend frontend/, + ); + assert.match( + script, + /docker compose --env-file \.env\.production -f docker-compose\.prod\.yml ps/, + ); +}); + test('nginx configs target the expected upstreams', () => { const apiConfig = readInfraFile('nginx/api.drdnt.org.conf'); const stationConfig = readInfraFile('nginx/station.drdnt.org.conf'); @@ -128,7 +145,9 @@ test('nginx configs target the expected upstreams', () => { assert.match(apiConfig, /proxy_pass http:\/\/127\.0\.0\.1:3001;/); assert.match(stationConfig, /server_name station\.drdnt\.org;/); - assert.match(stationConfig, /proxy_pass http:\/\/127\.0\.0\.1:5173;/); + assert.match(stationConfig, /location \/api\/ \{/); + assert.match(stationConfig, /proxy_pass http:\/\/127\.0\.0\.1:3001;/); + assert.match(stationConfig, /proxy_pass http:\/\/127\.0\.0\.1:3000;/); assert.match(botConfig, /server_name bot\.drdnt\.org;/); assert.match(botConfig, /proxy_pass http:\/\/127\.0\.0\.1:3999;/); From 44ffddba7dd62426dee79e61b548cad0d43ccaec Mon Sep 17 00:00:00 2001 From: Demian Date: Fri, 24 Apr 2026 03:37:22 -0400 Subject: [PATCH 3/5] fix: address PR 138 review feedback - quote the production cleanup cron expression in the env template --- .env.production.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.production.example b/.env.production.example index df9f53a..42ccb42 100644 --- a/.env.production.example +++ b/.env.production.example @@ -17,7 +17,7 @@ REDIS_PORT=6379 REDIS_PASSWORD=replace-with-a-strong-redis-password USE_REDIS_CACHE=true -REFRESH_TOKEN_CLEANUP_CRON=0 3 * * * +REFRESH_TOKEN_CLEANUP_CRON="0 3 * * *" THROTTLE_TTL_MS=60000 THROTTLE_LIMIT=100 From 7c2e0490d62af6e2900e49081d7a2d0885714b64 Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Fri, 24 Apr 2026 19:13:52 -0400 Subject: [PATCH 4/5] fix: wire redis auth in production compose --- backend/src/app.module.ts | 1 + backend/src/config/env.validation.ts | 1 + docker-compose.prod.yml | 6 ++---- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9e63050..d920ed5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -89,6 +89,7 @@ if (!isTest) { host: configService.get('REDIS_HOST', 'localhost'), port: configService.get('REDIS_PORT', 6379), }, + password: configService.get('REDIS_PASSWORD') || undefined, ttl: 300000, // 5 minutes default TTL in milliseconds }); console.log('✅ Redis cache connected successfully'); diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index d72b73f..3957455 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -22,6 +22,7 @@ export const envValidationSchema = Joi.object({ // Redis REDIS_HOST: Joi.string().default('localhost'), REDIS_PORT: Joi.number().default(6379), + REDIS_PASSWORD: Joi.string().allow('').default(''), USE_REDIS_CACHE: Joi.string().valid('true', 'false').default('true'), // CORS / Frontend — required in production; default to localhost in dev/test diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1d23adb..0310b4b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -34,8 +34,6 @@ services: postgres: image: postgres:16-alpine restart: unless-stopped - env_file: - - .env.production environment: POSTGRES_USER: ${DATABASE_USER} POSTGRES_PASSWORD: ${DATABASE_PASSWORD} @@ -56,8 +54,8 @@ services: redis: image: redis:7-alpine restart: unless-stopped - env_file: - - .env.production + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} command: sh -c 'redis-server --requirepass "$$REDIS_PASSWORD" --appendonly yes' volumes: - redis_data:/data From aefbd7d1ab8f8e09840cfd88b98b0e636b1d5074 Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Fri, 24 Apr 2026 21:03:25 -0400 Subject: [PATCH 5/5] fix: harden production compose validation --- docker-compose.prod.yml | 10 +++++----- infra/tests/infrastructure.test.mjs | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0310b4b..ca3718b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -35,16 +35,16 @@ services: image: postgres:16-alpine restart: unless-stopped environment: - POSTGRES_USER: ${DATABASE_USER} - POSTGRES_PASSWORD: ${DATABASE_PASSWORD} - POSTGRES_DB: ${DATABASE_NAME} + POSTGRES_USER: ${DATABASE_USER:?DATABASE_USER is required} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD is required} + POSTGRES_DB: ${DATABASE_NAME:?DATABASE_NAME is required} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: [ 'CMD-SHELL', - 'pg_isready -U ${DATABASE_USER} -d ${DATABASE_NAME}', + 'pg_isready -U ${DATABASE_USER:?DATABASE_USER is required} -d ${DATABASE_NAME:?DATABASE_NAME is required}', ] interval: 10s timeout: 5s @@ -55,7 +55,7 @@ services: image: redis:7-alpine restart: unless-stopped environment: - REDIS_PASSWORD: ${REDIS_PASSWORD} + REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set in .env.production} command: sh -c 'redis-server --requirepass "$$REDIS_PASSWORD" --appendonly yes' volumes: - redis_data:/data diff --git a/infra/tests/infrastructure.test.mjs b/infra/tests/infrastructure.test.mjs index 6a113fd..26f4f20 100644 --- a/infra/tests/infrastructure.test.mjs +++ b/infra/tests/infrastructure.test.mjs @@ -78,13 +78,17 @@ test('bash scripts have valid shell syntax', () => { return; } + const scripts = [ + path.join(infraRoot, 'scripts/bootstrap-vps.sh'), + path.join(infraRoot, 'scripts/setup-swap.sh'), + path.join(infraRoot, 'scripts/issue-certs.sh'), + path.join(infraRoot, 'scripts/deploy.sh'), + ]; + try { - execFileSync('bash', [ - '-n', - path.join(infraRoot, 'scripts/bootstrap-vps.sh'), - path.join(infraRoot, 'scripts/setup-swap.sh'), - path.join(infraRoot, 'scripts/issue-certs.sh'), - ]); + for (const script of scripts) { + execFileSync('bash', ['-n', script]); + } } catch (error) { if (error && typeof error === 'object' && 'code' in error) { const code = String(error.code); @@ -177,8 +181,10 @@ test('infra scripts are executable on disk', () => { ).mode; const swapMode = statSync(path.join(infraRoot, 'scripts/setup-swap.sh')).mode; const certMode = statSync(path.join(infraRoot, 'scripts/issue-certs.sh')).mode; + const deployMode = statSync(path.join(infraRoot, 'scripts/deploy.sh')).mode; assert.ok(bootstrapMode & 0o111); assert.ok(swapMode & 0o111); assert.ok(certMode & 0o111); + assert.ok(deployMode & 0o111); });