Checklist before going to production
Nginx reverse proxy
The server listens on HTTP. Terminate TLS at Nginx and proxy to the container.
server {
listen 443 ssl http2;
server_name governance.yourdomain.com;
ssl_certificate /etc/ssl/certs/governance.yourdomain.com.pem;
ssl_certificate_key /etc/ssl/private/governance.yourdomain.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
# Match server bodyLimit of 64 KB
client_max_body_size 128k;
location / {
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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
}
server {
listen 80;
server_name governance.yourdomain.com;
return 301 https://$host$request_uri;
}
Production docker-compose.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: Parmana_audit
POSTGRES_USER: Parmana
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_password
volumes:
- postgres_data:/var/lib/postgresql/data
# Do NOT expose port 5432 to the host in production
restart: always
networks:
- parmana-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U Parmana -d Parmana_audit"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
redis:
image: redis:7
command:
- redis-server
- --appendonly
- yes
- --requirepass
- "${REDIS_PASSWORD}"
# Do NOT expose Redis port to the host in production
restart: always
networks:
- parmana-net
server:
image: ghcr.io/pavancharak/parmanasystems/server:${RELEASE_TAG}
environment:
PORT: 3000
HOST: 127.0.0.1 # bind to loopback only; Nginx proxies
CORS_ORIGIN: https://dashboard.yourdomain.com
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
AUDIT_DATABASE_URL: postgresql://Parmana:${POSTGRES_PASSWORD}@postgres:5432/Parmana_audit
PARMANA_API_KEY: ${PARMANA_API_KEY}
PARMANA_SIGNING_PROVIDER: env
PARMANA_SIGNING_PRIVATE_KEY: ${PARMANA_SIGNING_PRIVATE_KEY}
PARMANA_SIGNING_PUBLIC_KEY: ${PARMANA_SIGNING_PUBLIC_KEY}
PARMANA_POLICIES_ROOT: /app/policies
PARMANA_TRUST_ROOT: /app/trust/trust-root.json
PARMANA_TRUST_PUBLIC_KEY: /app/trust/root.pub
PARMANA_RELEASE_MANIFEST: /app/artifacts/release-manifest.json
PARMANA_RELEASE_SIGNATURE: /app/artifacts/release-manifest.sig
ports:
- "127.0.0.1:3000:3000" # only reachable from localhost (Nginx)
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
restart: always
networks:
- parmana-net
networks:
parmana-net:
driver: bridge
volumes:
postgres_data:
secrets:
postgres_password:
external: true
Secrets management
Docker secrets (recommended for Docker Swarm)
echo "your-postgres-password" | docker secret create postgres_password -
echo "your-api-key" | docker secret create parmana_api_key -
Reference in compose:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_password
Environment injection from a secrets manager
For non-Swarm deployments, inject secrets at deploy time from your secrets manager:
# AWS Secrets Manager example
aws secretsmanager get-secret-value \
--secret-id parmana/production \
--query SecretString \
--output text | jq -r 'to_entries | .[] | "\(.key)=\(.value)"' > .env.production
docker compose --env-file .env.production up -d
Never store Ed25519 private keys in .env files committed to version control. The key material in PARMANA_SIGNING_PRIVATE_KEY is the root of trust for all attestation signatures. Compromise of this key invalidates future verifications against it — all past attestations remain valid and verifiable against the old public key.
Persistent volumes
Postgres data is in the postgres_data named volume. Map it to a host directory for easier backup:
volumes:
postgres_data:
driver: local
driver_opts:
type: none
o: bind
device: /data/parmana/postgres
Ensure /data/parmana/postgres exists and is owned by UID 999 (the Postgres container user):
mkdir -p /data/parmana/postgres
chown 999:999 /data/parmana/postgres
Backup strategy
Postgres
Use pg_dump for logical backups on a schedule:
#!/bin/bash
# /etc/cron.daily/parmana-backup
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=/backups/parmana
docker compose exec -T postgres \
pg_dump -U Parmana Parmana_audit \
| gzip > "$BACKUP_DIR/audit_$TIMESTAMP.sql.gz"
# Retain 30 days
find "$BACKUP_DIR" -name "audit_*.sql.gz" -mtime +30 -delete
Redis
Redis AOF (--appendonly yes) persists all writes to disk. For additional safety, take periodic snapshots:
docker compose exec redis redis-cli BGSAVE
Rate limits
The server enforces per-route rate limits:
| Endpoint | Limit |
|---|
POST /execute | 100 requests per minute |
| All other routes | Fastify default (configurable) |
When the limit is exceeded, the server returns 429 with a reset Unix timestamp. Configure additional rate limiting at the Nginx layer for DDoS protection.
Troubleshooting
Server exits with [SYS-REPLAY-001]
REDIS_URL is empty or Redis is not reachable. Verify the Redis service is running and the URL is correct (including password if set).
Nginx returns 502 Bad Gateway
The server is not listening on 127.0.0.1:3000. Check:
docker compose logs server --tail=30
ss -tlnp | grep 3000
Attestation signatures stop verifying after key rotation
All attestations signed with the old key remain valid against the old public key. Distribute the old public key to any verifier that holds historical attestations. New attestations use the new key. See Trust Chain for key rotation guidance.
Postgres out of disk
Attestation records are compact (typically 2–4 KB each). At 100,000 decisions per day, expect roughly 200–400 MB/day. Monitor disk usage and set up archival for the audit_decisions table if needed.
verification: "error" in /health after deploy
The signing key failed to load. Check that PARMANA_SIGNING_PROVIDER matches the key source (env or disk) and that the key value or path is correct. Run docker compose logs server --tail=20 for the specific error.