Guide for deploying OpenJustice to production with security hardening.
This guide provides step-by-step instructions for deploying OpenJustice to a production Ubuntu 22.04 server. Follow this guide after completing Environment Setup.
Estimated time: 4-6 hours
Prerequisites:
- Ubuntu 22.04 LTS server configured (Environment Setup)
- Domain name with DNS configured
- Root/sudo access
- PostgreSQL 15+ installed and configured
- Redis 7+ installed
- S3 storage credentials
Internet
↓
Nginx (443) ← SSL/TLS (Let's Encrypt)
↓
├─→ Frontend (Next.js) :3000
└─→ Backend (NestJS) :4000
↓
├─→ PostgreSQL :5432
├─→ Redis :6379
└─→ S3 Storage (MinIO/AWS)
Create dedicated system user for running OpenJustice (security best practice):
# Create openjustice user without home directory login
sudo useradd -r -s /bin/bash -m -d /opt/openjustice openjustice
# Create application directory
sudo mkdir -p /opt/openjustice
sudo chown openjustice:openjustice /opt/openjusticeWhy dedicated user?
- Principle of least privilege
- Process isolation
- Easier permission management
- Security containment
# Switch to openjustice user
sudo su - openjustice
# Clone repository
cd /opt/openjustice
git clone https://github.com/your-org/openjustice.git
cd openjustice
# Checkout production branch or tag
git checkout v1.0.0 # or main for latestcd /opt/openjustice/openjustice/openjustice-server
# Install dependencies (production only)
npm ci --omit=dev
# Generate Prisma client
npx prisma generate
# Build TypeScript application
npm run buildVerify build:
ls -la dist/
# Should see compiled JS files# Create production .env file
nano .envProduction .env configuration:
# ============== DATABASE ==============
DATABASE_URL="postgresql://openjustice_user:SECURE_PASSWORD@localhost:5432/openjustice_production?sslmode=require"
DIRECT_DATABASE_URL="postgresql://openjustice_user:SECURE_PASSWORD@localhost:5432/openjustice_production?sslmode=require"
# ============== APPLICATION ==============
PORT=4000
NODE_ENV=production
API_PREFIX=api/v1
CORS_ORIGIN=https://openjustice.yourpolice.gov
# ============== AUTHENTICATION ==============
# Generate with: openssl rand -base64 32
JWT_SECRET=<YOUR_GENERATED_JWT_SECRET_HERE>
JWT_EXPIRY=15m
JWT_REFRESH_SECRET=<YOUR_GENERATED_REFRESH_SECRET_HERE>
JWT_REFRESH_EXPIRY=7d
MAX_FAILED_ATTEMPTS=5
LOCK_DURATION_MINUTES=30
PIN_EXPIRY_DAYS=90
# ============== ENCRYPTION ==============
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=<YOUR_GENERATED_ENCRYPTION_KEY_HERE>
# ============== S3 STORAGE ==============
S3_ENDPOINT=https://s3.amazonaws.com
S3_ACCESS_KEY=<YOUR_AWS_ACCESS_KEY>
S3_SECRET_KEY=<YOUR_AWS_SECRET_KEY>
S3_BUCKET=yourorg-openjustice-evidence-prod
S3_REGION=us-east-1
# ============== REDIS ==============
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=<YOUR_REDIS_PASSWORD>
REDIS_DB=0
# ============== WHATSAPP ==============
WHAPI_URL=https://gate.whapi.cloud
WHAPI_TOKEN=<YOUR_WHAPI_TOKEN>
ENABLE_WHATSAPP=true
# ============== FEATURE FLAGS ==============
ENABLE_MFA=true
ENABLE_USSD=true
ENABLE_OFFLINE=true
# ============== LOGGING ==============
LOG_LEVEL=warn
LOG_PRETTY=false # JSON logs for production
# ============== RATE LIMITING ==============
THROTTLE_TTL=60
THROTTLE_LIMIT=100Secure the .env file:
chmod 600 .env
chown openjustice:openjustice .envFor production deployments, consider migrating secrets out of .env files into a dedicated secrets manager:
- HashiCorp Vault -- recommended for self-hosted deployments. Store
JWT_SECRET,JWT_REFRESH_SECRET,ENCRYPTION_KEY, database credentials, and S3 keys in Vault and inject them at runtime. - AWS Secrets Manager / Azure Key Vault / GCP Secret Manager -- if deploying on a cloud provider, use the native secrets manager to rotate credentials automatically.
- Environment injection -- use systemd
EnvironmentFileor Docker secrets instead of.envfiles on disk.
At minimum, ensure:
.envfiles are never committed to version control (already enforced via.gitignore)- File permissions are restricted to the application user (
chmod 600) ENCRYPTION_KEYis backed up securely -- losing this key means all encrypted PII becomes unrecoverableJWT_SECRETandJWT_REFRESH_SECRETare rotated periodically (e.g., quarterly)- S3 credentials use IAM roles with least-privilege policies where possible
# Run migrations
npx prisma migrate deploy
# Verify migration
npx prisma migrate status# Seed roles, permissions, initial admin
npx prisma db seedDefault admin credentials created:
- Badge:
SA-00001 - PIN:
12345678(CHANGE IMMEDIATELY after first login)
Important: Change default admin PIN immediately after deployment.
cd /opt/openjustice/openjustice/openjustice-client
# Install dependencies
npm ci --omit=dev
# Create production environment file
nano .env.production.localFrontend .env.production.local:
# ============== API ==============
NEXT_PUBLIC_API_URL=https://api.openjustice.yourpolice.gov/api/v1
# ============== APPLICATION ==============
NEXT_PUBLIC_APP_NAME=OpenJustice - Your Police Force
NEXT_PUBLIC_APP_VERSION=1.0.0
NEXT_PUBLIC_COUNTRY_CODE=SL # Change to your country code
# ============== FEATURE FLAGS ==============
NEXT_PUBLIC_ENABLE_MFA=true
NEXT_PUBLIC_ENABLE_OFFLINE=true
NEXT_PUBLIC_ENABLE_GEOCRIME=true
# ============== MAPS ==============
NEXT_PUBLIC_MAPBOX_TOKEN=<YOUR_MAPBOX_TOKEN>
NEXT_PUBLIC_MAP_CENTER_LAT=8.4657 # Customize for your country
NEXT_PUBLIC_MAP_CENTER_LNG=-13.2317
NEXT_PUBLIC_MAP_ZOOM=11
# ============== DEVELOPMENT ==============
NEXT_PUBLIC_ENABLE_DEVTOOLS=false
NEXT_PUBLIC_DEBUG=falseBuild frontend:
npm run build
# Verify build
ls -la .next/# Install PM2 globally
sudo npm install -g pm2
# Allow openjustice user to use PM2
sudo chown -R openjustice:openjustice /opt/openjusticecd /opt/openjustice/openjustice/openjustice-server
# Create PM2 ecosystem file
nano ecosystem.config.jsecosystem.config.js:
module.exports = {
apps: [
{
name: 'openjustice-api',
script: 'dist/main.js',
cwd: '/opt/openjustice/openjustice/openjustice-server',
instances: 2, // Cluster mode with 2 instances
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
},
error_file: '/var/log/openjustice/api-error.log',
out_file: '/var/log/openjustice/api-out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true,
autorestart: true,
max_restarts: 10,
min_uptime: '10s',
max_memory_restart: '1G',
},
],
};Create log directory:
sudo mkdir -p /var/log/openjustice
sudo chown -R openjustice:openjustice /var/log/openjusticeStart backend:
pm2 start ecosystem.config.js
pm2 savecd /opt/openjustice/openjustice/openjustice-client
# Create PM2 config
nano ecosystem.config.jsecosystem.config.js:
module.exports = {
apps: [
{
name: 'openjustice-web',
script: 'npm',
args: 'start',
cwd: '/opt/openjustice/openjustice/openjustice-client',
instances: 1,
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
error_file: '/var/log/openjustice/web-error.log',
out_file: '/var/log/openjustice/web-out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true,
autorestart: true,
max_restarts: 10,
min_uptime: '10s',
max_memory_restart: '1G',
},
],
};Start frontend:
pm2 start ecosystem.config.js
pm2 save# Generate startup script for automatic restart on reboot
pm2 startup systemd -u openjustice --hp /opt/openjustice
# Copy and run the generated command (output from above)
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u openjustice --hp /opt/openjustice
# Save PM2 process list
pm2 saveVerify processes:
pm2 list
pm2 logssudo nano /etc/nginx/sites-available/openjusticeNginx configuration:
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=oj_api:10m rate=100r/m;
limit_req_zone $binary_remote_addr zone=oj_web:10m rate=200r/m;
# Backend API server
server {
listen 80;
server_name api.openjustice.yourpolice.gov;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name api.openjustice.yourpolice.gov;
# SSL certificates (configured by Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/api.openjustice.yourpolice.gov/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.openjustice.yourpolice.gov/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/api.openjustice.yourpolice.gov/chain.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Logging
access_log /var/log/nginx/openjustice-api-access.log;
error_log /var/log/nginx/openjustice-api-error.log;
# Rate limiting
limit_req zone=oj_api burst=20 nodelay;
# Proxy to backend
location / {
proxy_pass http://localhost:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffer settings
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# File upload size limit (for evidence files)
client_max_body_size 100M;
}
# Frontend web application
server {
listen 80;
server_name openjustice.yourpolice.gov www.openjustice.yourpolice.gov;
# Redirect HTTP to HTTPS
return 301 https://openjustice.yourpolice.gov$request_uri;
}
server {
listen 443 ssl http2;
server_name openjustice.yourpolice.gov www.openjustice.yourpolice.gov;
# SSL certificates
ssl_certificate /etc/letsencrypt/live/openjustice.yourpolice.gov/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openjustice.yourpolice.gov/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/openjustice.yourpolice.gov/chain.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Logging
access_log /var/log/nginx/openjustice-web-access.log;
error_log /var/log/nginx/openjustice-web-error.log;
# Rate limiting
limit_req zone=oj_web burst=50 nodelay;
# Proxy to Next.js
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Next.js static files
location /_next/static {
proxy_pass http://localhost:3000/_next/static;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Service Worker
location /sw.js {
proxy_pass http://localhost:3000/sw.js;
add_header Cache-Control "public, max-age=0, must-revalidate";
}
# PWA manifest
location /manifest.json {
proxy_pass http://localhost:3000/manifest.json;
add_header Cache-Control "public, max-age=86400";
}
client_max_body_size 10M;
}# Enable site
sudo ln -s /etc/nginx/sites-available/openjustice /etc/nginx/sites-enabled/
# Remove default site
sudo rm /etc/nginx/sites-enabled/default
# Test configuration
sudo nginx -t
# Reload Nginx
sudo systemctl reload nginxSee SSL/TLS Certificates for detailed Let's Encrypt setup.
Quick setup:
# Install Certbot
sudo apt install certbot python3-certbot-nginx -y
# Obtain certificates
sudo certbot --nginx -d api.openjustice.yourpolice.gov -d openjustice.yourpolice.gov -d www.openjustice.yourpolice.gov
# Test auto-renewal
sudo certbot renew --dry-run# Allow SSH (if not already allowed)
sudo ufw allow OpenSSH
# Allow HTTP and HTTPS
sudo ufw allow 'Nginx Full'
# Deny direct access to application ports
sudo ufw deny 3000
sudo ufw deny 4000
# Enable firewall
sudo ufw enable
# Check status
sudo ufw statusFirewall rules:
Status: active
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
Nginx Full ALLOW Anywhere
3000 DENY Anywhere
4000 DENY Anywhere
sudo nano /etc/ssh/sshd_configSet:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
Restart SSH:
sudo systemctl restart sshd# Install
sudo apt install fail2ban -y
# Configure
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.localAdd OpenJustice-specific jails:
[nginx-req-limit]
enabled = true
filter = nginx-req-limit
action = iptables-multiport[name=ReqLimit, port="http,https", protocol=tcp]
logpath = /var/log/nginx/*error.log
findtime = 600
bantime = 7200
maxretry = 10Restart Fail2Ban:
sudo systemctl restart fail2ban
sudo fail2ban-client status# Install unattended-upgrades
sudo apt install unattended-upgrades -y
# Enable
sudo dpkg-reconfigure --priority=low unattended-upgradessudo nano /etc/logrotate.d/openjusticeLog rotation config:
/var/log/openjustice/*.log {
daily
missingok
rotate 30
compress
delaycompress
notifempty
create 0640 openjustice openjustice
sharedscripts
postrotate
pm2 reloadLogs
endscript
}
curl -X GET https://api.openjustice.yourpolice.gov/api/v1/healthExpected response:
{
"status": "ok",
"info": {
"database": { "status": "up" },
"redis": { "status": "up" },
"storage": { "status": "up" }
},
"error": {},
"details": {
"database": { "status": "up" },
"redis": { "status": "up" },
"storage": { "status": "up" }
}
}curl -I https://openjustice.yourpolice.govExpected: HTTP 200 OK
# As openjustice user
sudo su - openjustice
cd /opt/openjustice/openjustice/openjustice-server
npx prisma db execute --stdin <<< "SELECT COUNT(*) FROM officers;"# Login to web interface
# Navigate to https://openjustice.yourpolice.gov
# Login with SA-00001 / 12345678
# Go to Profile → Change PIN
# Set new secure PINSee Creating Officers.
See Monitoring & Logging.
See Backup & Restore.
Check PM2 logs:
pm2 logs openjustice-api --lines 100
pm2 logs openjustice-web --lines 100Common issues:
- Database connection failed → Check DATABASE_URL
- Port already in use → Check with
sudo lsof -i :4000 - Missing .env file → Verify .env exists and has correct permissions
Check backend is running:
pm2 list
curl http://localhost:4000/api/v1/healthCheck Nginx error logs:
sudo tail -f /var/log/nginx/openjustice-api-error.logCheck migration status:
npx prisma migrate statusReset and retry (DESTRUCTIVE - development only):
npx prisma migrate reset
npx prisma migrate deployVerify certificates:
sudo certbot certificatesRenew manually:
sudo certbot renew --force-renewalBefore going live, verify:
Security:
- HTTPS enabled and enforced
- Firewall configured
- SSH hardened (key-only, no root)
- Fail2Ban installed
- Strong JWT secrets generated
- Database passwords rotated
- Default admin PIN changed
Application:
- Backend health check passing
- Frontend accessible
- Database migrations applied
- Initial data seeded
- File uploads working (S3)
- Authentication flow tested
Infrastructure:
- PM2 startup script configured
- Nginx reverse proxy working
- Log rotation configured
- Automated backups scheduled
- Monitoring configured
- DNS configured correctly
Compliance:
- Privacy policy reviewed
- Terms of service configured
- Audit logging enabled
- Data retention policy set
Documentation:
- Admin credentials documented (securely)
- Deployment architecture documented
- Rollback procedure documented
- Incident response plan prepared
If deployment fails, rollback:
pm2 stop allsudo -u postgres psql -c "DROP DATABASE openjustice_production;"
sudo -u postgres psql -c "CREATE DATABASE openjustice_production OWNER openjustice_user;"
sudo -u postgres pg_restore -d openjustice_production /backups/openjustice_production_backup.sqlcd /opt/openjustice/openjustice
git checkout previous-stable-tag
cd openjustice-server && npm run build
cd ../openjustice-client && npm run buildpm2 restart all# Stop application
pm2 stop all
# Backup database first
sudo -u postgres pg_dump openjustice_production > /backups/pre-update-$(date +%Y%m%d).sql
# Pull latest code
cd /opt/openjustice/openjustice
git pull origin main
# Update backend
cd openjustice-server
npm ci --omit=dev
npx prisma generate
npx prisma migrate deploy
npm run build
# Update frontend
cd ../openjustice-client
npm ci --omit=dev
npm run build
# Restart
pm2 restart all
# Verify
curl https://api.openjustice.yourpolice.gov/api/v1/healthDeployment complete! Next:
- Monitoring — Set up monitoring and alerts
- Backup & Restore — Configure automated backups
- Creating Officers — Enroll officers
- Country Customization — Customize for your country
For deployment issues:
- Check logs:
/var/log/openjustice/and/var/log/nginx/ - Review Troubleshooting Guide
- Open GitHub issue with deployment logs
Security note: Never skip security hardening steps in production. This system handles sensitive criminal justice data.