> ## Documentation Index
> Fetch the complete documentation index at: https://docs.manthan.systems/llms.txt
> Use this file to discover all available pages before exploring further.

# Production Deployment

> Nginx reverse proxy, TLS, persistent volumes, secrets, and backup strategy for production

## 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.

```nginx theme={null}
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

```yaml theme={null}
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)

```bash theme={null}
echo "your-postgres-password" | docker secret create postgres_password -
echo "your-api-key"           | docker secret create parmana_api_key -
```

Reference in compose:

```yaml theme={null}
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:

```bash theme={null}
# 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
```

<Warning>
  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.
</Warning>

***

## Persistent volumes

Postgres data is in the `postgres_data` named volume. Map it to a host directory for easier backup:

```yaml theme={null}
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):

```bash theme={null}
mkdir -p /data/parmana/postgres
chown 999:999 /data/parmana/postgres
```

***

## Backup strategy

### Postgres

Use `pg_dump` for logical backups on a schedule:

```bash theme={null}
#!/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:

```bash theme={null}
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:

```bash theme={null}
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](/architecture/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.
