Production Deployment Guide
Overview
Section titled “Overview”This guide covers deploying NORA in production environments with HTTPS/TLS support using reverse proxies.
Reverse Proxy Setup
Section titled “Reverse Proxy Setup”Option 1: Caddy (Recommended)
Section titled “Option 1: Caddy (Recommended)”Caddy provides automatic HTTPS with Let’s Encrypt and simple configuration.
Caddyfile:
nora.example.com { reverse_proxy localhost:4000 { header_up Host {host} header_up X-Real-IP {remote} header_up X-Forwarded-For {remote} header_up X-Forwarded-Proto {scheme} }}With custom certificate:
{ auto_https disable_redirects}
nora.example.com:443 { tls /etc/ssl/certs/nora.crt /etc/ssl/private/nora.key
reverse_proxy localhost:4000 { header_up Host {host} header_up X-Real-IP {remote} }
log { output file /var/log/caddy/nora-access.log }}Start Caddy:
caddy run --config /etc/caddy/CaddyfileOption 2: Nginx
Section titled “Option 2: Nginx”nginx.conf:
upstream nora_backend { server localhost:4000;}
server { listen 443 ssl http2; server_name nora.example.com;
ssl_certificate /etc/ssl/certs/nora.crt; ssl_certificate_key /etc/ssl/private/nora.key;
# SSL hardening ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on;
# Proxy settings location / { proxy_pass http://nora_backend; 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;
# Timeouts for large image uploads proxy_connect_timeout 300s; proxy_send_timeout 300s; proxy_read_timeout 300s; }
# Metrics endpoint location /metrics { proxy_pass http://localhost:4000/metrics; allow 10.0.0.0/8; # Restrict to internal network deny all; }}
# HTTP to HTTPS redirectserver { listen 80; server_name nora.example.com; return 301 https://$host$request_uri;}Option 3: Traefik
Section titled “Option 3: Traefik”docker-compose.yml with Traefik:
version: '3.8'
services: nora: image: ghcr.io/getnora-io/nora:latest restart: unless-stopped volumes: - nora-data:/data environment: NORA_STORAGE_PATH: /data labels: - "traefik.enable=true" - "traefik.http.routers.nora.rule=Host(`nora.example.com`)" - "traefik.http.routers.nora.entrypoints=websecure" - "traefik.http.routers.nora.tls.certresolver=letsencrypt" - "traefik.http.services.nora.loadbalancer.server.port=4000"
traefik: image: traefik:v2.10 restart: unless-stopped ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock - traefik-certs:/letsencrypt command: - "--providers.docker=true" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.email=admin@example.com" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
volumes: nora-data: traefik-certs:The client address behind a proxy
Section titled “The client address behind a proxy”NORA keys its rate limits and its failed-login lockout by the client address. It reads X-Forwarded-For (then X-Real-IP) only when the request comes from an address in NORA_AUTH_TRUSTED_PROXIES (default 127.0.0.1,::1), and takes the rightmost address in the header that is not itself a trusted proxy.
- A proxy on the same host as NORA (the Caddy and Nginx examples) connects from loopback and works as shown.
- A proxy in another container (the Traefik example, the Compose stack below) connects from its container address, not loopback. Set
NORA_AUTH_TRUSTED_PROXIESto that network, for example172.16.0.0/12; otherwise every client shares the proxy’s limits and its failed-login lockout. - With more than one proxy in front of NORA (a load balancer, then Nginx), list every hop. An unlisted hop becomes the client address.
Docker Compose Production Setup
Section titled “Docker Compose Production Setup”Hardened production stack:
services: nora: image: ghcr.io/getnora-io/nora:0.9.0 container_name: nora restart: unless-stopped read_only: true tmpfs: - /tmp:size=256M ports: - "127.0.0.1:4000:4000" volumes: - nora-data:/data - ./config.toml:/etc/nora/config.toml:ro environment: NORA_HOST: "0.0.0.0" NORA_PORT: "4000" NORA_STORAGE_PATH: /data NORA_CONFIG_PATH: /etc/nora/config.toml NORA_PUBLIC_URL: ${NORA_PUBLIC_URL} NORA_AUTH_ENABLED: "true" RUST_LOG: "info" deploy: resources: limits: memory: 512M cpus: "2" reservations: memory: 128M cpus: "0.25" healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/health"] interval: 15s timeout: 5s start_period: 10s retries: 3 logging: driver: json-file options: max-size: "10m" max-file: "3" security_opt: - no-new-privileges:true
caddy: image: caddy:2-alpine container_name: caddy restart: unless-stopped ports: - "443:443" - "80:80" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config depends_on: nora: condition: service_healthy
volumes: nora-data: caddy-data: caddy-config:Key hardening points:
read_only: true— filesystem is immutable except for mounted volumesno-new-privileges— prevents privilege escalationtmpfsfor/tmp— no persistent temp files on disk- Resource limits prevent runaway memory usage
- Healthcheck with
start_periodgives time for startup - Log rotation with
max-sizeandmax-file
Start:
NORA_PUBLIC_URL=https://registry.example.com docker compose up -dCertificate Management
Section titled “Certificate Management”Let’s Encrypt (Automatic)
Section titled “Let’s Encrypt (Automatic)”Caddy handles this automatically. Just use hostname in Caddyfile:
nora.example.com { reverse_proxy localhost:4000}Custom CA (FreeIPA, Internal PKI)
Section titled “Custom CA (FreeIPA, Internal PKI)”1. Generate certificate request:
openssl req -new -newkey rsa:2048 -nodes \ -keyout nora.key \ -out nora.csr \ -subj "/CN=nora.example.com/O=MyOrg/C=US"2. Sign with your CA and install:
# Copy signed certificate and keycp nora.crt /etc/ssl/certs/cp nora.key /etc/ssl/private/chmod 600 /etc/ssl/private/nora.key
# Add CA certificate to system trust storecp ca.crt /usr/local/share/ca-certificates/update-ca-certificates3. Configure reverse proxy with custom cert (see examples above)
Client Configuration
Section titled “Client Configuration”Docker
Section titled “Docker”On client machines, add CA certificate:
# Linuxsudo cp ca.crt /usr/local/share/ca-certificates/sudo update-ca-certificatessudo systemctl restart docker
# macOSsudo security add-trusted-cert -d -r trustRoot \ -k /Library/Keychains/System.keychain ca.crtVerify access:
docker login nora.example.comdocker pull nora.example.com/myapp:latestKubernetes
Section titled “Kubernetes”Add CA to all nodes:
# On each nodesudo cp ca.crt /usr/local/share/ca-certificates/nora-ca.crtsudo update-ca-certificatessudo systemctl restart containerdUpdate image references in manifests:
containers:- name: myapp image: nora.example.com/myapp:latestSystemd Service
Section titled “Systemd Service”Create /etc/systemd/system/nora.service:
[Unit]Description=NORA Artifact RegistryDocumentation=https://getnora.devAfter=network-online.targetWants=network-online.target
[Service]Type=simpleUser=noraGroup=noraExecStart=/usr/local/bin/nora serveExecReload=/bin/kill -HUP $MAINPIDRestart=on-failureRestartSec=5
# EnvironmentEnvironment=RUST_LOG=infoEnvironment=NORA_HOST=0.0.0.0Environment=NORA_PORT=4000Environment=NORA_STORAGE_PATH=/var/lib/noraEnvironment=NORA_CONFIG_PATH=/etc/nora/config.tomlEnvironmentFile=-/etc/nora/nora.env
# Security hardeningNoNewPrivileges=trueProtectSystem=strictProtectHome=trueReadWritePaths=/var/lib/noraPrivateTmp=truePrivateDevices=trueProtectKernelTunables=trueProtectKernelModules=trueProtectControlGroups=true
# Resource limitsLimitNOFILE=65535LimitNPROC=4096
[Install]WantedBy=multi-user.targetInstall and start:
# Create service useruseradd --system --no-create-home --shell /sbin/nologin noramkdir -p /var/lib/nora /etc/norachown nora:nora /var/lib/nora
# Install binarycp target/release/nora /usr/local/bin/chmod 755 /usr/local/bin/nora
# Enable and startsystemctl daemon-reloadsystemctl enable --now noraHot Reload (SIGHUP)
Section titled “Hot Reload (SIGHUP)”NORA supports hot-reloading the curation policy without restarting the server. Send SIGHUP to reload config.toml and apply updated curation rules instantly:
# systemdsystemctl reload nora
# Dockerdocker kill --signal=HUP nora
# Manualkill -HUP $(pidof nora)What gets reloaded
Section titled “What gets reloaded”| Setting | Hot-reloaded | Requires restart |
|---|---|---|
| Curation mode (off/audit/enforce) | Yes | — |
| Curation allowlist/blocklist | Yes | — |
| Curation bypass token | Yes | — |
| Internal namespaces | Yes | — |
| Min release age | Yes | — |
| Server host/port | — | Yes |
| Storage settings | — | Yes |
| Auth providers (OIDC) | — | Yes |
| Docker upstreams | — | Yes |
Behavior on invalid config
Section titled “Behavior on invalid config”If the reloaded config is invalid (TOML parse error, missing required fields), NORA logs a warning and keeps the previous configuration. The server never crashes due to a bad reload:
WARN Curation policy reload failed, keeping previous config error="TOML parse error at line 12"Zero-downtime policy update
Section titled “Zero-downtime policy update”- Edit
/etc/nora/config.toml(e.g., add a package to blocklist) - Reload:
systemctl reload nora - Verify:
journalctl -u nora --since "1 min ago" | grep reload
Monitoring
Section titled “Monitoring”Health check endpoint:
curl https://nora.example.com/health# Expected: {"status":"healthy"}Metrics endpoint (Prometheus):
curl http://localhost:4000/metricsSee Monitoring Guide for Prometheus/Grafana setup.
Performance Tuning
Section titled “Performance Tuning”Rate Limits
Section titled “Rate Limits”For heavy CI/CD environments, increase rate limits:
NORA_RATE_LIMIT_UPLOAD_RPS=2000 # Default: 200NORA_RATE_LIMIT_UPLOAD_BURST=5000 # Default: 500NORA_RATE_LIMIT_GENERAL_RPS=1000 # Default: 100NORA_RATE_LIMIT_GENERAL_BURST=2000 # Default: 200Monitor /metrics for rate limit indicators to tune appropriately.
Storage
Section titled “Storage”Local storage:
- Use fast SSD for
/data - Regular cleanup of old tags
- Monitor disk usage
- Writes are staged in
.nora-staging/<instance>/under the storage root and renamed into place. Each running instance locks its own directory; at start NORA removes the directories of instances that are gone, so a crash orkill -9leaves nothing behind after the next start. Two instances on one volume (a rolling update) do not touch each other’s directory. A registry directory mounted separately falls back to staging next to the key. .nora-origins/holds npm cache provenance records (see npm). Keep it: without it, cached packages of owned scopes are fetched again from their upstream.nora backup,restoreandmigratecarry it.
S3-compatible storage:
NORA_STORAGE_MODE=s3NORA_STORAGE_S3_URL=https://s3.eu-central-1.amazonaws.comNORA_STORAGE_BUCKET=nora-registryNORA_STORAGE_S3_REGION=eu-central-1See S3 Storage for full setup with MinIO, RustFS, or AWS S3.
Security Considerations
Section titled “Security Considerations”- Always use HTTPS in production - Docker/containerd require secure registries
- Restrict metrics endpoint - Use firewall or reverse proxy rules
- Enable authentication - See Authentication
- Keep CA certificates updated on all client machines
- Monitor for unauthorized access via logs and metrics. Credentials in an upstream URL (
https://user:pass@host) are masked in log output, metric labels and error responses; prefer each upstream’sauthsetting (andauth_bearer_envfor npm) over URL credentials anyway - Regular backups of
/datadirectory
Troubleshooting
Section titled “Troubleshooting”Problem: “x509: certificate signed by unknown authority”
Solution: Install CA certificate on client machines (see Client Configuration above)
Problem: Rate limit errors
Solution: Increase rate limits via environment variables and restart NORA
Problem: Slow uploads
Solution: Check reverse proxy timeouts, increase if needed