feat: harden containers and ci
This commit is contained in:
@@ -7,6 +7,12 @@ POSTGRES_DB=merchantsofhope_supplyanddemandportal
|
|||||||
POSTGRES_USER=merchantsofhope_user
|
POSTGRES_USER=merchantsofhope_user
|
||||||
POSTGRES_PASSWORD=merchantsofhope_password
|
POSTGRES_PASSWORD=merchantsofhope_password
|
||||||
|
|
||||||
|
# Optional database pool tuning
|
||||||
|
DB_POOL_MAX=10
|
||||||
|
DB_POOL_IDLE_MS=30000
|
||||||
|
DB_POOL_CONNECTION_TIMEOUT_MS=5000
|
||||||
|
DB_WAIT_TIMEOUT_MS=60000
|
||||||
|
|
||||||
# Backend Application
|
# Backend Application
|
||||||
JWT_SECRET=a_much_stronger_and_longer_secret_key_for_jwt_that_is_not_in_git
|
JWT_SECRET=a_much_stronger_and_longer_secret_key_for_jwt_that_is_not_in_git
|
||||||
RATE_LIMIT_WINDOW_MS=900000
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -83,6 +83,16 @@ RATE_LIMIT_MAX=100
|
|||||||
|
|
||||||
# Uploads directory (overridable if you mount elsewhere)
|
# Uploads directory (overridable if you mount elsewhere)
|
||||||
UPLOAD_DIR=uploads/resumes
|
UPLOAD_DIR=uploads/resumes
|
||||||
|
|
||||||
|
# Optional entrypoint controls
|
||||||
|
RUN_MIGRATIONS=true
|
||||||
|
RUN_SEED=false
|
||||||
|
|
||||||
|
# Database pool and wait tuning (milliseconds)
|
||||||
|
DB_POOL_MAX=10
|
||||||
|
DB_POOL_IDLE_MS=30000
|
||||||
|
DB_POOL_CONNECTION_TIMEOUT_MS=5000
|
||||||
|
DB_WAIT_TIMEOUT_MS=60000
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `POSTGRES_PASSWORD` and `JWT_SECRET` before launching the stack. See `.env.example` for the full list.
|
Set `POSTGRES_PASSWORD` and `JWT_SECRET` before launching the stack. See `.env.example` for the full list.
|
||||||
@@ -165,6 +175,7 @@ To mirror CI in one shot, run the helper script from the repository root:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The script executes linting followed by the backend and frontend test suites (each with coverage reporting). Coverage thresholds are enforced by Jest to guard against regressions.
|
The script executes linting followed by the backend and frontend test suites (each with coverage reporting). Coverage thresholds are enforced by Jest to guard against regressions.
|
||||||
|
The helper waits for the ad-hoc PostgreSQL container, honours the backend entrypoint (which now blocks on database readiness, runs migrations, and optionally seeds), and then runs both suites with coverage thresholds enforced.
|
||||||
|
|
||||||
## Continuous Integration
|
## Continuous Integration
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
FROM node:18-alpine
|
# syntax=docker/dockerfile:1.4
|
||||||
|
|
||||||
|
FROM node:18-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
FROM base AS dev
|
||||||
|
ENV NODE_ENV=development
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN chmod +x docker-entrypoint.sh scripts/wait-for-db.js
|
||||||
ENV HOST=0.0.0.0 \
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
PORT=3001
|
|
||||||
|
|
||||||
EXPOSE 3001
|
|
||||||
|
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["npm", "run", "dev"]
|
||||||
|
|
||||||
|
FROM base AS prod-deps
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
FROM node:18-alpine AS prod
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
PORT=3001
|
||||||
|
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN chmod +x docker-entrypoint.sh scripts/wait-for-db.js
|
||||||
|
EXPOSE 3001
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
|
|||||||
21
backend/docker-entrypoint.sh
Executable file
21
backend/docker-entrypoint.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RUN_MIGRATIONS="${RUN_MIGRATIONS:-true}"
|
||||||
|
RUN_SEED="${RUN_SEED:-false}"
|
||||||
|
|
||||||
|
if [ "${SKIP_DB_WAIT:-false}" != "true" ]; then
|
||||||
|
node ./scripts/wait-for-db.js
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${RUN_MIGRATIONS}" = "true" ]; then
|
||||||
|
echo ">> Running database migrations"
|
||||||
|
npm run migrate
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${RUN_SEED}" = "true" ]; then
|
||||||
|
echo ">> Seeding database"
|
||||||
|
npm run seed || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
65
backend/scripts/wait-for-db.js
Executable file
65
backend/scripts/wait-for-db.js
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
const SECOND = 1000;
|
||||||
|
const DEFAULT_TIMEOUT = Number.parseInt(process.env.DB_WAIT_TIMEOUT_MS || `${60 * SECOND}`, 10);
|
||||||
|
const RETRY_INTERVAL = Number.parseInt(process.env.DB_WAIT_RETRY_MS || '2000', 10);
|
||||||
|
|
||||||
|
function deriveConnectionString() {
|
||||||
|
if (process.env.DATABASE_URL) {
|
||||||
|
return process.env.DATABASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
POSTGRES_USER = 'merchantsofhope_user',
|
||||||
|
POSTGRES_PASSWORD,
|
||||||
|
POSTGRES_DB = 'merchantsofhope_supplyanddemandportal',
|
||||||
|
POSTGRES_HOST = 'merchantsofhope-supplyanddemandportal-database',
|
||||||
|
POSTGRES_PORT = '5432'
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
if (!POSTGRES_PASSWORD) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionString = deriveConnectionString();
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
console.error('[wait-for-db] DATABASE_URL or POSTGRES_PASSWORD must be provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
async function waitForDatabase() {
|
||||||
|
const client = new Client({
|
||||||
|
connectionString,
|
||||||
|
connectionTimeoutMillis: Math.min(RETRY_INTERVAL, DEFAULT_TIMEOUT)
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
} catch (error) {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
if (elapsed >= DEFAULT_TIMEOUT) {
|
||||||
|
console.error('[wait-for-db] Timed out waiting for database connection.');
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = DEFAULT_TIMEOUT - elapsed;
|
||||||
|
const delay = Math.min(RETRY_INTERVAL, remaining);
|
||||||
|
console.info(`[wait-for-db] Database not ready yet. Retrying in ${delay}ms...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
return waitForDatabase();
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('[wait-for-db] Database connection established.');
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForDatabase().then(() => process.exit(0));
|
||||||
@@ -39,6 +39,11 @@ const config = {
|
|||||||
rateLimit: {
|
rateLimit: {
|
||||||
windowMs: optionalNumber(process.env.RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000),
|
windowMs: optionalNumber(process.env.RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000),
|
||||||
max: optionalNumber(process.env.RATE_LIMIT_MAX, 100)
|
max: optionalNumber(process.env.RATE_LIMIT_MAX, 100)
|
||||||
|
},
|
||||||
|
db: {
|
||||||
|
max: optionalNumber(process.env.DB_POOL_MAX, 10),
|
||||||
|
idleTimeoutMillis: optionalNumber(process.env.DB_POOL_IDLE_MS, 30000),
|
||||||
|
connectionTimeoutMillis: optionalNumber(process.env.DB_POOL_CONNECTION_TIMEOUT_MS, 5000)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ const config = require('../config');
|
|||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
connectionString: config.databaseUrl,
|
connectionString: config.databaseUrl,
|
||||||
ssl: config.env === 'production' ? { rejectUnauthorized: false } : false
|
ssl: config.env === 'production' ? { rejectUnauthorized: false } : false,
|
||||||
|
max: config.db.max,
|
||||||
|
idleTimeoutMillis: config.db.idleTimeoutMillis,
|
||||||
|
connectionTimeoutMillis: config.db.connectionTimeoutMillis
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test database connection
|
// Test database connection
|
||||||
|
|||||||
@@ -1,6 +1,45 @@
|
|||||||
const app = require('./server');
|
const app = require('./server');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
|
const pool = require('./database/connection');
|
||||||
|
|
||||||
app.listen(config.port, config.host, () => {
|
const server = app.listen(config.port, config.host, () => {
|
||||||
console.info(`MerchantsOfHope-SupplyANdDemandPortal backend server running on ${config.host}:${config.port}`);
|
console.info(`MerchantsOfHope-SupplyANdDemandPortal backend server running on ${config.host}:${config.port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let terminating = false;
|
||||||
|
|
||||||
|
async function shutdown(reason, exitCode = 0) {
|
||||||
|
if (terminating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminating = true;
|
||||||
|
console.info(`Shutting down server (${reason})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server.close(resolve);
|
||||||
|
});
|
||||||
|
await pool.end();
|
||||||
|
console.info('Server shutdown complete.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during shutdown:', error);
|
||||||
|
exitCode = exitCode || 1;
|
||||||
|
} finally {
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
['SIGTERM', 'SIGINT'].forEach((signal) => {
|
||||||
|
process.on(signal, () => shutdown(signal));
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
console.error('Unhandled promise rejection:', reason);
|
||||||
|
shutdown('unhandledRejection', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('Uncaught exception:', error);
|
||||||
|
shutdown('uncaughtException', 1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,16 +28,25 @@ services:
|
|||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-merchantsofhope}:${POSTGRES_PASSWORD}@merchantsofhope-supplyanddemandportal-database:5432/${POSTGRES_DB:-merchantsofhope_supplyanddemandportal}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-merchantsofhope}:${POSTGRES_PASSWORD}@merchantsofhope-supplyanddemandportal-database:5432/${POSTGRES_DB:-merchantsofhope_supplyanddemandportal}
|
||||||
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET}
|
||||||
UPLOAD_DIR: /app/uploads/resumes
|
UPLOAD_DIR: /app/uploads/resumes
|
||||||
|
RUN_MIGRATIONS: "true"
|
||||||
|
RUN_SEED: "false"
|
||||||
|
DB_WAIT_TIMEOUT_MS: 120000
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:3001:3001"
|
- "0.0.0.0:3001:3001"
|
||||||
volumes:
|
volumes:
|
||||||
- merchantsofhope-supplyanddemandportal-uploads:/app/uploads/resumes
|
- merchantsofhope-supplyanddemandportal-uploads:/app/uploads/resumes
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
merchantsofhope-supplyanddemandportal-frontend:
|
merchantsofhope-supplyanddemandportal-frontend:
|
||||||
image: ${FRONTEND_IMAGE:?set FRONTEND_IMAGE}
|
image: ${FRONTEND_IMAGE:?set FRONTEND_IMAGE}
|
||||||
container_name: merchantsofhope-supplyanddemandportal-frontend
|
container_name: merchantsofhope-supplyanddemandportal-frontend
|
||||||
depends_on:
|
depends_on:
|
||||||
- merchantsofhope-supplyanddemandportal-backend
|
merchantsofhope-supplyanddemandportal-backend:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 12000
|
PORT: 12000
|
||||||
|
|||||||
@@ -22,22 +22,27 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: dev
|
||||||
container_name: merchantsofhope-supplyanddemandportal-backend
|
container_name: merchantsofhope-supplyanddemandportal-backend
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-merchantsofhope_user}:${POSTGRES_PASSWORD}@merchantsofhope-supplyanddemandportal-database:5432/${POSTGRES_DB:-merchantsofhope_supplyanddemandportal}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-merchantsofhope_user}:${POSTGRES_PASSWORD}@merchantsofhope-supplyanddemandportal-database:5432/${POSTGRES_DB:-merchantsofhope_supplyanddemandportal}
|
||||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is not set}
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is not set}
|
||||||
HOST: ${BACKEND_HOST:-0.0.0.0}
|
HOST: ${BACKEND_HOST:-0.0.0.0}
|
||||||
PORT: ${BACKEND_PORT:-3001}
|
PORT: ${BACKEND_PORT:-3001}
|
||||||
POSTGRES_HOST: merchantsofhope-supplyanddemandportal-database
|
POSTGRES_HOST: merchantsofhope-supplyanddemandportal-database
|
||||||
UPLOAD_DIR: /app/uploads/resumes
|
UPLOAD_DIR: /app/uploads/resumes
|
||||||
|
RUN_MIGRATIONS: "true"
|
||||||
|
RUN_SEED: "false"
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:${BACKEND_PORT:-3001}:3001"
|
- "0.0.0.0:${BACKEND_PORT:-3001}:3001"
|
||||||
command: >
|
|
||||||
sh -c "npm run migrate && npm run seed && npm run dev"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
merchantsofhope-supplyanddemandportal-database:
|
merchantsofhope-supplyanddemandportal-database:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- backend-resume-uploads:/app/uploads/resumes
|
- backend-resume-uploads:/app/uploads/resumes
|
||||||
|
|||||||
88
docs/OPERATIONS_RUNBOOK.md
Normal file
88
docs/OPERATIONS_RUNBOOK.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Operations Runbook
|
||||||
|
|
||||||
|
This document captures the operational playbooks for the MerchantsOfHope Supply & Demand Portal. It is intended for on-call engineers and SREs maintaining the platform across Coolify environments.
|
||||||
|
|
||||||
|
## 1. Service Topology
|
||||||
|
|
||||||
|
- **Backend API (`merchantsofhope-supplyanddemandportal-backend`)**
|
||||||
|
- Node.js 18, Express server on port 3001.
|
||||||
|
- Entry point waits for PostgreSQL, runs migrations, optional seeding (`RUN_SEED`).
|
||||||
|
- Health probe: `GET /api/health`.
|
||||||
|
- Persistent uploads stored at `/app/uploads/resumes` (mounted volume required in production).
|
||||||
|
- **Frontend (`merchantsofhope-supplyanddemandportal-frontend`)**
|
||||||
|
- React 18 application served by the CRA dev server (in dev) or static bundle (in production image).
|
||||||
|
- Communicates with the backend via `REACT_APP_API_URL` (set to the internal service URL).
|
||||||
|
- **PostgreSQL (`merchantsofhope-supplyanddemandportal-database`)**
|
||||||
|
- PostgreSQL 15, health-checked via `pg_isready`.
|
||||||
|
- Volume-backed data directory `merchantsofhope-supplyanddemandportal-postgres-data`.
|
||||||
|
|
||||||
|
## 2. Environment Variables
|
||||||
|
|
||||||
|
| Variable | Purpose | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POSTGRES_*` | Database credentials used by backend and DB container | See `.env.example` |
|
||||||
|
| `DATABASE_URL` | Overrides assembled connection string | Derived automatically |
|
||||||
|
| `JWT_SECRET` | Required for signing auth tokens | none (must be supplied) |
|
||||||
|
| `RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS` | Express rate limiter configuration | 100 req / 15 min |
|
||||||
|
| `DB_POOL_MAX`, `DB_POOL_IDLE_MS`, `DB_POOL_CONNECTION_TIMEOUT_MS` | pg connection pool tuning | 10 / 30000 / 5000 |
|
||||||
|
| `DB_WAIT_TIMEOUT_MS` | Maximum wait for database readiness in entrypoint | 60000 |
|
||||||
|
| `RUN_MIGRATIONS` | Run schema migrations on container boot | `true` |
|
||||||
|
| `RUN_SEED` | Run seed data on container boot | `false` |
|
||||||
|
| `UPLOAD_DIR` | Resume storage path | `uploads/resumes` |
|
||||||
|
|
||||||
|
## 3. Deployments (Coolify)
|
||||||
|
|
||||||
|
1. Ensure the Gitea pipeline has published new backend/frontend images (see workflow summary for SHA tags).
|
||||||
|
2. In Coolify, update `BACKEND_IMAGE` / `FRONTEND_IMAGE` environment variables to the new tags.
|
||||||
|
3. Trigger a deployment; Coolify will:
|
||||||
|
- Bring up PostgreSQL (if not already running).
|
||||||
|
- Start backend, wait for DB, run migrations, and expose `/api/health`.
|
||||||
|
- Start frontend once backend healthcheck passes.
|
||||||
|
4. Post-deploy checks:
|
||||||
|
- `curl https://<domain>/api/health` returns `200` with JSON payload.
|
||||||
|
- Frontend login screen reachable.
|
||||||
|
- Review container logs for migration output (`docker compose logs backend` in Coolify shell).
|
||||||
|
|
||||||
|
## 4. Rollback Procedure
|
||||||
|
|
||||||
|
1. Identify the previous known-good image tags (from Gitea workflow history or Coolify activity log).
|
||||||
|
2. Update `BACKEND_IMAGE` / `FRONTEND_IMAGE` to the old tags.
|
||||||
|
3. Redeploy in Coolify. Migrations are idempotent; no additional action needed.
|
||||||
|
4. Validate health endpoints and smoke-test the UI.
|
||||||
|
|
||||||
|
## 5. Local Development
|
||||||
|
|
||||||
|
- Run `docker compose up --build` to start the stack. The backend container waits for PostgreSQL, runs migrations automatically, and skips seeding by default. To seed once, run `RUN_SEED=true docker compose up backend` or execute `docker compose exec ... npm run seed` manually.
|
||||||
|
- `./scripts/run-ci-tests.sh` runs lint + unit tests with the same coverage thresholds as CI.
|
||||||
|
- Backend tests rely on Docker; ensure Docker Desktop/Engine is running.
|
||||||
|
|
||||||
|
## 6. Backup & Restore
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- Use `docker compose exec merchantsofhope-supplyanddemandportal-database pg_dump -U <user> <db>` to generate a dump file.
|
||||||
|
- Restore via `psql` piping the dump into the running container.
|
||||||
|
|
||||||
|
### Uploads
|
||||||
|
- Archive the `merchantsofhope-supplyanddemandportal-uploads` volume (Coolify: Settings → Backups → Volume Snapshot).
|
||||||
|
|
||||||
|
## 7. Monitoring & Alerting
|
||||||
|
|
||||||
|
- Healthcheck endpoints should be wired into external monitoring (e.g., Uptime Kuma, Grafana Cloud).
|
||||||
|
- Rate limiter defaults protect against bursts; adjust `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MS` if legitimate traffic patterns trigger 429s.
|
||||||
|
|
||||||
|
## 8. Incident Response Checklist
|
||||||
|
|
||||||
|
1. **Validate Health** – `curl` backend health endpoint, inspect Coolify container logs.
|
||||||
|
2. **Check Database** – `docker compose exec ... pg_isready` and `
|
||||||
|
docker compose exec ... psql -c 'SELECT NOW();'`.
|
||||||
|
3. **Restart Services** – In Coolify or locally, redeploy backend/front containers (entrypoint will re-run migrations safely).
|
||||||
|
4. **Rollback if Needed** – Follow rollback steps above.
|
||||||
|
5. **Postmortem** – Capture root cause, update this runbook with remediation notes.
|
||||||
|
|
||||||
|
## 9. Security Posture
|
||||||
|
|
||||||
|
- JWT secrets must be at least 32 bytes and rotated regularly.
|
||||||
|
- Uploaded files are sanitized and stored on disk; configure antivirus scanning if compliance requires it.
|
||||||
|
- Rate limiting is enabled globally; consider pairing with IP allowlists at the reverse proxy if stricter controls are needed.
|
||||||
|
|
||||||
|
Keep this runbook updated as infrastructure evolves.
|
||||||
@@ -28,6 +28,16 @@
|
|||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"lint": "eslint --ext js,jsx src"
|
"lint": "eslint --ext js,jsx src"
|
||||||
},
|
},
|
||||||
|
"jest": {
|
||||||
|
"coverageThreshold": {
|
||||||
|
"global": {
|
||||||
|
"statements": 18,
|
||||||
|
"branches": 7,
|
||||||
|
"functions": 12,
|
||||||
|
"lines": 18
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
"react-app",
|
"react-app",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jest.mock('react-router-dom', () => ({
|
|||||||
describe('Layout', () => {
|
describe('Layout', () => {
|
||||||
const renderLayout = () => {
|
const renderLayout = () => {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter>
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<Layout />
|
<Layout />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jest.mock('react-query', () => ({
|
|||||||
|
|
||||||
const renderJobs = () => {
|
const renderJobs = () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<Jobs />
|
<Jobs />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user