Skip to content

Production Setup Guide

This guide walks you through deploying the Panels Management System to production. The system consists of:

  • Frontend: Next.js 15 app with React 19 and Tailwind CSS
  • Backend: Fastify 5.3.2 API with TypeScript
  • Database: PostgreSQL 16 with MikroORM
  • Cache: Redis 7
  • FHIR Server: Medplum for healthcare data management

System Requirements

Minimum Production Requirements

  • CPU: 4 cores
  • RAM: 8GB
  • Storage: 100GB SSD
  • Network: 1Gbps connection
  • OS: Ubuntu 22.04 LTS or Amazon Linux 2023
  • CPU: 8 cores
  • RAM: 16GB
  • Storage: 500GB SSD (with automated backups)
  • Network: 10Gbps connection
  • Load Balancer: nginx or AWS ALB
  • CDN: CloudFront or CloudFlare

Docker Production Setup

1. Create Production Docker Compose

Create docker-compose.prod.yml:

yaml
version: '3.8'

services:
  panels-db:
    image: postgres:16
    container_name: panels-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: panels_prod
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./backups:/backups
    networks:
      - panels-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 30s
      timeout: 10s
      retries: 3

  panels-redis:
    image: redis:7-alpine
    container_name: panels-redis
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 2gb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - panels-network
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 30s
      timeout: 10s
      retries: 3

  medplum-server:
    image: medplum/medplum-server:latest
    container_name: medplum-server
    restart: unless-stopped
    depends_on:
      panels-db:
        condition: service_healthy
      panels-redis:
        condition: service_healthy
    environment:
      MEDPLUM_PORT: 8103
      MEDPLUM_BASE_URL: ${MEDPLUM_BASE_URL}
      MEDPLUM_DATABASE_HOST: panels-db
      MEDPLUM_DATABASE_PORT: 5432
      MEDPLUM_DATABASE_DBNAME: panels_prod
      MEDPLUM_DATABASE_USERNAME: ${DB_USER}
      MEDPLUM_DATABASE_PASSWORD: ${DB_PASSWORD}
      MEDPLUM_REDIS_HOST: panels-redis
      MEDPLUM_REDIS_PORT: 6379
      MEDPLUM_REDIS_PASSWORD: ${REDIS_PASSWORD}
      MEDPLUM_BINARY_STORAGE: file:./binary/
      MEDPLUM_MAX_JSON_SIZE: 10mb
      MEDPLUM_MAX_BATCH_SIZE: 100mb
    volumes:
      - medplum_data:/usr/src/medplum/packages/server/binary
    networks:
      - panels-network
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://localhost:8103/healthcheck').then(r => r.json()).then(console.log).catch(() => { process.exit(1); })"]
      interval: 30s
      timeout: 10s
      retries: 3

  panels-api:
    build:
      context: .
      dockerfile: apps/services/Dockerfile.prod
    container_name: panels-api
    restart: unless-stopped
    depends_on:
      panels-db:
        condition: service_healthy
      panels-redis:
        condition: service_healthy
      medplum-server:
        condition: service_healthy
    environment:
      NODE_ENV: production
      DB_HOST: panels-db
      DB_PORT: 5432
      DB_NAME: panels_prod
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: panels-redis
      REDIS_PORT: 6379
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      JWT_SECRET: ${JWT_SECRET}
      MEDPLUM_BASE_URL: http://medplum-server:8103
      API_PORT: 3001
    networks:
      - panels-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  panels-app:
    build:
      context: .
      dockerfile: apps/app/Dockerfile.prod
    container_name: panels-app
    restart: unless-stopped
    depends_on:
      panels-api:
        condition: service_healthy
    environment:
      NODE_ENV: production
      API_URL: ${API_BASE_URL}
      NEXTAUTH_URL: ${APP_BASE_URL}
      NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
    networks:
      - panels-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  nginx:
    image: nginx:alpine
    container_name: panels-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/ssl:/etc/nginx/ssl
      - ./nginx/logs:/var/log/nginx
    depends_on:
      - panels-app
      - panels-api
    networks:
      - panels-network

volumes:
  postgres_data:
  redis_data:
  medplum_data:

networks:
  panels-network:
    driver: bridge

2. Create Production Dockerfiles

Backend Dockerfile (apps/services/Dockerfile.prod):

dockerfile
FROM node:22-alpine AS base
WORKDIR /app
RUN apk add --no-cache curl

# Install pnpm
RUN npm install -g pnpm@10.11.0

# Copy package files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/ ./packages/
COPY apps/services/package.json ./apps/services/

# Install dependencies
RUN pnpm install --frozen-lockfile --prod

# Build stage
FROM base AS build
COPY apps/services/ ./apps/services/
COPY packages/ ./packages/
RUN pnpm run build --filter=@panels/services

# Production stage
FROM node:22-alpine AS production
WORKDIR /app
RUN apk add --no-cache curl

# Copy built application
COPY --from=build /app/apps/services/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/services/package.json ./

# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S panels -u 1001
USER panels

EXPOSE 3001

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:3001/health || exit 1

CMD ["node", "--enable-source-maps", "dist/server.js"]

Frontend Dockerfile (apps/app/Dockerfile.prod):

dockerfile
FROM node:22-alpine AS base
WORKDIR /app
RUN apk add --no-cache libc6-compat curl

# Install pnpm
RUN npm install -g pnpm@10.11.0

FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/ ./packages/
COPY apps/app/package.json ./apps/app/
RUN pnpm install --frozen-lockfile

FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm run build --filter=@panels/app

FROM base AS production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=build /app/apps/app/public ./public
COPY --from=build --chown=nextjs:nodejs /app/apps/app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/apps/app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:3000/api/health || exit 1

CMD ["node", "server.js"]

nginx Configuration

Create nginx/nginx.conf:

nginx
events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # Logging
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;
    error_log /var/log/nginx/error.log;

    # Basic settings
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;
    server_tokens off;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types
        text/plain
        text/css
        text/xml
        text/javascript
        application/json
        application/javascript
        application/xml+rss
        application/atom+xml
        image/svg+xml;

    # Rate limiting
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=app:10m rate=5r/s;

    # Upstream servers
    upstream panels_api {
        server panels-api:3001 max_fails=3 fail_timeout=30s;
    }

    upstream panels_app {
        server panels-app:3000 max_fails=3 fail_timeout=30s;
    }

    # HTTP to HTTPS redirect
    server {
        listen 80;
        server_name _;
        return 301 https://$host$request_uri;
    }

    # Main server
    server {
        listen 443 ssl http2;
        server_name your-domain.com;

        # SSL configuration
        ssl_certificate /etc/nginx/ssl/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/privkey.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
        ssl_prefer_server_ciphers off;
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 10m;

        # Security headers
        add_header X-Frame-Options DENY;
        add_header X-Content-Type-Options nosniff;
        add_header X-XSS-Protection "1; mode=block";
        add_header Referrer-Policy "strict-origin-when-cross-origin";
        add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';";

        # API routes
        location /api/ {
            limit_req zone=api burst=20 nodelay;
            proxy_pass http://panels_api/;
            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_connect_timeout 30s;
            proxy_send_timeout 30s;
            proxy_read_timeout 30s;
        }

        # Frontend application
        location / {
            limit_req zone=app burst=10 nodelay;
            proxy_pass http://panels_app;
            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_connect_timeout 30s;
            proxy_send_timeout 30s;
            proxy_read_timeout 30s;
        }

        # Static assets with caching
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
            proxy_pass http://panels_app;
        }
    }
}

Environment Configuration

Create .env.prod:

bash
# Database
DB_HOST=panels-db
DB_PORT=5432
DB_NAME=panels_prod
DB_USER=panels_admin
DB_PASSWORD=your_secure_db_password

# Redis
REDIS_HOST=panels-redis
REDIS_PORT=6379
REDIS_PASSWORD=your_secure_redis_password

# API
JWT_SECRET=your_jwt_secret_key_at_least_32_characters_long
API_PORT=3001
API_BASE_URL=https://your-domain.com/api

# App
APP_BASE_URL=https://your-domain.com
NEXTAUTH_URL=https://your-domain.com
NEXTAUTH_SECRET=your_nextauth_secret_key

# Medplum
MEDPLUM_BASE_URL=http://medplum-server:8103

SSL Certificate Setup

bash
# Install Certbot
sudo apt-get update
sudo apt-get install certbot python3-certbot-nginx

# Obtain certificate
sudo certbot --nginx -d your-domain.com

# Setup auto-renewal
echo "0 12 * * * /usr/bin/certbot renew --quiet" | sudo crontab -

Using Custom Certificates

bash
# Create SSL directory
mkdir -p nginx/ssl

# Copy your certificates
cp your-domain.com.crt nginx/ssl/fullchain.pem
cp your-domain.com.key nginx/ssl/privkey.pem

# Set proper permissions
chmod 600 nginx/ssl/privkey.pem
chmod 644 nginx/ssl/fullchain.pem

Deployment Process

1. Server Preparation

bash
# Update system
sudo apt-get update && sudo apt-get upgrade -y

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER

# Install Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

# Reboot to apply group changes
sudo reboot

2. Application Deployment

bash
# Clone repository
git clone https://github.com/your-org/panels.git
cd panels

# Copy production environment
cp .env.prod .env

# Edit environment variables
nano .env

# Deploy application
docker-compose -f docker-compose.prod.yml up -d

# Run database migrations
docker-compose -f docker-compose.prod.yml exec panels-api npm run migration:apply

3. Verify Deployment

bash
# Check all services are running
docker-compose -f docker-compose.prod.yml ps

# Check logs
docker-compose -f docker-compose.prod.yml logs -f

# Test endpoints
curl -f https://your-domain.com/api/health
curl -f https://your-domain.com

Monitoring and Maintenance

Health Checks

Create scripts/health-check.sh:

bash
#!/bin/bash

# Check all services
services=("panels-db" "panels-redis" "medplum-server" "panels-api" "panels-app" "panels-nginx")

for service in "${services[@]}"; do
    if ! docker ps | grep -q $service; then
        echo "ERROR: $service is not running"
        exit 1
    fi
done

# Check HTTP endpoints
if ! curl -f -s https://your-domain.com/api/health > /dev/null; then
    echo "ERROR: API health check failed"
    exit 1
fi

if ! curl -f -s https://your-domain.com > /dev/null; then
    echo "ERROR: App health check failed"
    exit 1
fi

echo "All health checks passed"

Backup Script

Create scripts/backup.sh:

bash
#!/bin/bash

BACKUP_DIR="/backups"
DATE=$(date +%Y%m%d_%H%M%S)

# Database backup
docker-compose -f docker-compose.prod.yml exec -T panels-db pg_dump -U $DB_USER -d $DB_NAME > $BACKUP_DIR/db_backup_$DATE.sql

# Redis backup
docker-compose -f docker-compose.prod.yml exec -T panels-redis redis-cli -a $REDIS_PASSWORD --rdb $BACKUP_DIR/redis_backup_$DATE.rdb

# Medplum data backup
docker cp medplum-server:/usr/src/medplum/packages/server/binary $BACKUP_DIR/medplum_backup_$DATE

# Cleanup old backups (keep last 7 days)
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete
find $BACKUP_DIR -name "*.rdb" -mtime +7 -delete
find $BACKUP_DIR -name "medplum_backup_*" -mtime +7 -exec rm -rf {} +

echo "Backup completed: $DATE"

Monitoring with Prometheus (Optional)

Add to docker-compose.prod.yml:

yaml
  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    networks:
      - panels-network

  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    ports:
      - "3000:3000"
    volumes:
      - grafana_data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    networks:
      - panels-network

Performance Optimization

Database Tuning

PostgreSQL configuration in postgresql.conf:

ini
# Memory settings
shared_buffers = 256MB
effective_cache_size = 1GB
work_mem = 4MB
maintenance_work_mem = 64MB

# Checkpoint settings
checkpoint_completion_target = 0.9
wal_buffers = 16MB
default_statistics_target = 100

# Connection settings
max_connections = 200

Redis Optimization

bash
# Add to redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
save 900 1
save 300 10
save 60 10000

Next.js Optimization

Update next.config.js:

javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  experimental: {
    outputFileTracingIncludes: {
      '/api/**/*': ['./node_modules/**/*'],
    },
  },
  compress: true,
  poweredByHeader: false,
  reactStrictMode: true,
  images: {
    formats: ['image/webp', 'image/avif'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
}

module.exports = nextConfig

Security Checklist

  • [ ] SSL/TLS certificates properly configured
  • [ ] Database credentials rotated and secured
  • [ ] JWT secrets are cryptographically secure
  • [ ] Rate limiting enabled
  • [ ] Security headers configured
  • [ ] Firewall rules configured
  • [ ] Regular security updates scheduled
  • [ ] Backup encryption enabled
  • [ ] Log monitoring configured
  • [ ] Intrusion detection enabled

Troubleshooting

Common Issues

Service won't start:

bash
# Check logs
docker-compose -f docker-compose.prod.yml logs service-name

# Check resource usage
docker stats

# Check disk space
df -h

Database connection issues:

bash
# Test database connection
docker-compose -f docker-compose.prod.yml exec panels-db psql -U $DB_USER -d $DB_NAME -c "SELECT 1;"

# Check database logs
docker-compose -f docker-compose.prod.yml logs panels-db

High memory usage:

bash
# Check memory usage
free -h
docker stats --no-stream

# Restart services if needed
docker-compose -f docker-compose.prod.yml restart

SSL certificate issues:

bash
# Check certificate expiry
openssl x509 -in nginx/ssl/fullchain.pem -text -noout | grep "Not After"

# Renew Let's Encrypt certificate
sudo certbot renew --dry-run

Scaling Considerations

Horizontal Scaling

  • Use a load balancer (AWS ALB, nginx upstream)
  • Deploy multiple API instances
  • Use Redis for session storage
  • Implement database read replicas
  • Use CDN for static assets

Vertical Scaling

  • Monitor CPU and memory usage
  • Optimize database queries
  • Implement caching strategies
  • Use connection pooling
  • Configure proper indexes

This production setup provides a robust, secure, and scalable deployment for the Panels Management System. Always test thoroughly in a staging environment before deploying to production.

Released under the Apache License 2.0.