Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ if (!isTest) {
host: configService.get<string>('REDIS_HOST', 'localhost'),
port: configService.get<number>('REDIS_PORT', 6379),
},
password: configService.get<string>('REDIS_PASSWORD') || undefined,
ttl: 300000, // 5 minutes default TTL in milliseconds
});
console.log('✅ Redis cache connected successfully');
Expand Down
1 change: 1 addition & 0 deletions backend/src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -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']
Comment thread
GitAddRemote marked this conversation as resolved.
interval: 10s
timeout: 3s
retries: 3
start_period: 5s

volumes:
postgres_data:
redis_data:
9 changes: 9 additions & 0 deletions infra/nginx/station.drdnt.org.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions infra/scripts/deploy.sh
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions infra/tests/infrastructure.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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/,
);
});
Comment thread
GitAddRemote marked this conversation as resolved.

test('nginx configs target the expected upstreams', () => {
const apiConfig = readInfraFile('nginx/api.drdnt.org.conf');
const stationConfig = readInfraFile('nginx/station.drdnt.org.conf');
Expand All @@ -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;/);
Expand All @@ -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);
});
Loading