diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..42ccb42 --- /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/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/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..ca3718b --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,71 @@ +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 + environment: + 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:?DATABASE_USER is required} -d ${DATABASE_NAME:?DATABASE_NAME is required}', + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + redis: + image: redis:7-alpine + restart: unless-stopped + environment: + 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 + 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 7b42bd1..86f734b 100644 --- a/infra/nginx/station.drdnt.org.conf +++ b/infra/nginx/station.drdnt.org.conf @@ -2,6 +2,15 @@ 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:3000; proxy_http_version 1.1; 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 diff --git a/infra/tests/infrastructure.test.mjs b/infra/tests/infrastructure.test.mjs index 251eff3..26f4f20 100644 --- a/infra/tests/infrastructure.test.mjs +++ b/infra/tests/infrastructure.test.mjs @@ -82,6 +82,7 @@ test('bash scripts have valid shell syntax', () => { 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 { @@ -136,6 +137,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'); @@ -145,6 +163,8 @@ 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, /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;/); @@ -161,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); });