Skip to main content

Checklist before going to production

  • TLS termination configured (Nginx or load balancer)
  • PARMANA_API_KEY is a cryptographically random value (min 32 bytes)
  • Signing key is stored in a secrets manager or HSM — not in .env
  • Redis has AOF persistence enabled (--appendonly yes)
  • Postgres data directory on a persistent, backed-up volume
  • Server not exposed directly to the internet — behind Nginx or a load balancer
  • CORS_ORIGIN set to your actual frontend origin
  • Postgres and Redis ports not exposed to the host

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

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:
EndpointLimit
POST /execute100 requests per minute
All other routesFastify 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.