Chapter 24: Deployment

Docker Deployment on Synology NAS

AdPriority runs on the same Synology NAS (192.168.1.26) that hosts the other Docker-based applications. It joins the shared postgres_default network to access the PostgreSQL 16 container, and uses a Cloudflare Tunnel for HTTPS access required by Shopify.

DEPLOYMENT ARCHITECTURE
=======================

  Internet                                 Synology NAS (192.168.1.26)
  --------                                 ---------------------------

  Shopify Admin                            +---------------------------+
  (embedded app)                           |  Docker Engine            |
       |                                   |                           |
       | HTTPS                             |  +---------------------+  |
       v                                   |  | cloudflared         |  |
  Cloudflare Tunnel ---------------------->|  | (tunnel container)  |  |
       |                                   |  +----------+----------+  |
       |                                   |             |             |
       +--- /api/*, /auth/*, /webhooks/ -->|  +----------v----------+  |
       |                                   |  | adpriority-backend  |  |
       |                                   |  | (Express + TS)      |  |
       |                                   |  | Port: 3010          |  |
       |                                   |  +----------+----------+  |
       |                                   |             |             |
       +--- /* (UI assets) -------------->|  +----------v----------+  |
                                           |  | adpriority-admin   |  |
                                           |  | (React + Vite)     |  |
                                           |  | Port: 3011         |  |
                                           |  +---------------------+  |
                                           |                           |
                                           |  +---------------------+  |
                                           |  | adpriority-worker   |  |
                                           |  | (Bull queue)        |  |
                                           |  +----------+----------+  |
                                           |             |             |
                                           |  +----------v----------+  |
                                           |  | redis               |  |
                                           |  | Port: 6379          |  |
                                           |  +---------------------+  |
                                           |             |             |
                                           |  +----------v----------+  |
                                           |  | postgres16          |  |
                                           |  | (shared)            |  |
                                           |  | DB: adpriority_db   |  |
                                           |  | Port: 5432          |  |
                                           |  +---------------------+  |
                                           +---------------------------+

Docker Compose Structure

Development Configuration

# /volume1/docker/adpriority/docker-compose.yml
version: "3.8"

services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: adpriority-backend
    ports:
      - "3010:3010"
    volumes:
      - ./backend/src:/app/src
      - ./backend/prisma:/app/prisma
    environment:
      - NODE_ENV=development
      - PORT=3010
      - DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_db
      - REDIS_URL=redis://redis:6379
      - SHOPIFY_CLIENT_ID=${SHOPIFY_CLIENT_ID}
      - SHOPIFY_CLIENT_SECRET=${SHOPIFY_CLIENT_SECRET}
      - SHOPIFY_SCOPES=read_products,write_products,read_inventory
      - HOST=https://${APP_DOMAIN}
      - GOOGLE_SHEETS_CREDENTIALS=${GOOGLE_SHEETS_CREDENTIALS}
      - ENCRYPTION_KEY_V1=${ENCRYPTION_KEY_V1}
    depends_on:
      - redis
    networks:
      - postgres_default
      - adpriority
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512M

  admin-ui:
    build:
      context: ./admin-ui
      dockerfile: Dockerfile
    container_name: adpriority-admin
    ports:
      - "3011:3011"
    volumes:
      - ./admin-ui/src:/app/src
    environment:
      - VITE_SHOPIFY_CLIENT_ID=${SHOPIFY_CLIENT_ID}
      - VITE_API_URL=https://${APP_DOMAIN}
    networks:
      - adpriority
    restart: unless-stopped

  worker:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: adpriority-worker
    command: ["npm", "run", "worker"]
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_db
      - REDIS_URL=redis://redis:6379
      - GOOGLE_SHEETS_CREDENTIALS=${GOOGLE_SHEETS_CREDENTIALS}
      - ENCRYPTION_KEY_V1=${ENCRYPTION_KEY_V1}
    depends_on:
      - redis
    networks:
      - postgres_default
      - adpriority
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512M

  redis:
    image: redis:7-alpine
    container_name: adpriority-redis
    ports:
      - "6380:6379"
    volumes:
      - redis-data:/data
    command: ["redis-server", "--appendonly", "yes"]
    networks:
      - adpriority
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 128M

volumes:
  redis-data:

networks:
  postgres_default:
    external: true
  adpriority:
    driver: bridge

Production Configuration

The production configuration removes volume mounts (no hot-reload), uses versioned images, and adds health checks.

# /volume1/docker/adpriority/docker-compose.prod.yml
version: "3.8"

services:
  backend:
    image: adpriority-backend:${VERSION:-latest}
    container_name: adpriority-backend
    ports:
      - "3010:3010"
    environment:
      - NODE_ENV=production
      - PORT=3010
      - DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_db
      - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
      - SHOPIFY_CLIENT_ID=${SHOPIFY_CLIENT_ID}
      - SHOPIFY_CLIENT_SECRET=${SHOPIFY_CLIENT_SECRET}
      - SHOPIFY_SCOPES=read_products,write_products,read_inventory
      - HOST=https://${APP_DOMAIN}
      - GOOGLE_SHEETS_CREDENTIALS=${GOOGLE_SHEETS_CREDENTIALS}
      - ENCRYPTION_KEY_V1=${ENCRYPTION_KEY_V1}
      - CSP_FRAME_ANCESTORS=https://*.myshopify.com https://admin.shopify.com
    depends_on:
      redis:
        condition: service_healthy
    networks:
      - postgres_default
      - adpriority
    restart: always
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3010/health/live"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s
    deploy:
      resources:
        limits:
          memory: 512M

  admin-ui:
    image: adpriority-admin:${VERSION:-latest}
    container_name: adpriority-admin
    ports:
      - "3011:3011"
    environment:
      - VITE_SHOPIFY_CLIENT_ID=${SHOPIFY_CLIENT_ID}
      - VITE_API_URL=https://${APP_DOMAIN}
    networks:
      - adpriority
    restart: always

  worker:
    image: adpriority-backend:${VERSION:-latest}
    container_name: adpriority-worker
    command: ["npm", "run", "worker"]
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_db
      - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
      - GOOGLE_SHEETS_CREDENTIALS=${GOOGLE_SHEETS_CREDENTIALS}
      - ENCRYPTION_KEY_V1=${ENCRYPTION_KEY_V1}
    depends_on:
      redis:
        condition: service_healthy
    networks:
      - postgres_default
      - adpriority
    restart: always
    deploy:
      resources:
        limits:
          memory: 512M

  redis:
    image: redis:7-alpine
    container_name: adpriority-redis
    volumes:
      - redis-data:/data
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"]
    networks:
      - adpriority
    restart: always
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 128M

volumes:
  redis-data:

networks:
  postgres_default:
    external: true
  adpriority:
    driver: bridge

Container Roles

ContainerImageRoleNetwork
adpriority-backendadpriority-backendExpress API server, handles HTTP requests, OAuth, webhookspostgres_default + adpriority
adpriority-adminadpriority-adminServes React/Polaris frontend (Nginx in production)adpriority
adpriority-workeradpriority-backend (same image, different entrypoint)Processes Bull queue jobs: syncs, seasonal transitions, bulk operationspostgres_default + adpriority
adpriority-redisredis:7-alpineJob queue backend, session cacheadpriority
postgres16Shared (external)PostgreSQL 16, database: adpriority_dbpostgres_default

The worker container uses the same Docker image as the backend but runs a different entrypoint (npm run worker) that starts the Bull queue processor instead of the Express server. This ensures the scoring engine and sync logic are identical between API-triggered and scheduled operations.


Cloudflare Tunnel Configuration

Shopify requires HTTPS callback URLs for OAuth redirects and webhook delivery. The Cloudflare Tunnel provides this without exposing ports on the NAS firewall.

Tunnel routing rules:

HostnameServicePurpose
adpriority.nexusclothing.synology.mehttp://localhost:3010API, auth, webhooks
adpriority.nexusclothing.synology.me (path: /*)http://localhost:3011Admin UI assets

The backend serves both API routes and proxies UI requests in production mode. In development, the Vite dev server runs separately on port 3011.

Tunnel config (added to existing cloudflared configuration):

# Addition to existing tunnel config
ingress:
  - hostname: adpriority.nexusclothing.synology.me
    service: http://localhost:3010
  # ... existing rules ...
  - service: http_status:404

Environment Variables

Required Variables

VariableDescriptionExample
SHOPIFY_CLIENT_IDApp client ID from Partner Dashboardabc123def456
SHOPIFY_CLIENT_SECRETApp client secret (never log this)shpss_xxxxxxxx
SHOPIFY_SCOPESOAuth scopesread_products,write_products,read_inventory
DATABASE_URLPostgreSQL connection stringpostgresql://adpriority_user:pass@postgres16:5432/adpriority_db
DB_PASSWORDDatabase password (referenced in DATABASE_URL)AdPrioritySecure2026
REDIS_URLRedis connection stringredis://:${REDIS_PASSWORD}@redis:6379
REDIS_PASSWORDRedis authentication passwordRandom 32-character string
HOSTPublic HTTPS URL (for OAuth redirects)https://adpriority.nexusclothing.synology.me
APP_DOMAINDomain without protocoladpriority.nexusclothing.synology.me
ENCRYPTION_KEY_V1AES-256 key for token encryption (versioned, see ADR-011)32-byte hex string
GOOGLE_SHEETS_CREDENTIALSService account JSON (base64 encoded)Base64 string

Optional Variables

VariableDescriptionDefault
PORTBackend server port3010
NODE_ENVRuntime environmentdevelopment
LOG_LEVELLogging verbosityinfo
SYNC_FREQUENCY_MINUTESDefault sync interval360 (6 hours)
NEW_ARRIVAL_DAYSDays to consider a product “new”14
SENTRY_DSNError tracking (optional)Not set

Environment File Template

# /volume1/docker/adpriority/.env.example

# Shopify App
SHOPIFY_CLIENT_ID=
SHOPIFY_CLIENT_SECRET=
SHOPIFY_SCOPES=read_products,write_products,read_inventory

# Database
DB_PASSWORD=          # REQUIRED: generate with `openssl rand -base64 32`
DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_db

# Redis
REDIS_PASSWORD=       # REQUIRED: generate with `openssl rand -base64 32`
REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379

# Application
HOST=https://adpriority.nexusclothing.synology.me
APP_DOMAIN=adpriority.nexusclothing.synology.me
PORT=3010
NODE_ENV=development

# Security (versioned encryption keys, see ADR-011)
ENCRYPTION_KEY_V1=    # REQUIRED: generate with `openssl rand -hex 32`

# Google
GOOGLE_SHEETS_CREDENTIALS=

# Version (for production images)
VERSION=latest

CI/CD Pipeline

GitHub Actions Workflow

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set version
        run: |
          if [[ "${{ github.ref }}" == refs/tags/* ]]; then
            echo "VERSION=${{ github.ref_name }}" >> $GITHUB_ENV
          else
            echo "VERSION=latest" >> $GITHUB_ENV
          fi

      - name: Install dependencies and run tests
        run: |
          cd backend && npm ci && npm test
          cd ../admin-ui && npm ci && npm test

      - name: Build backend image
        run: |
          docker build -t adpriority-backend:${{ env.VERSION }} ./backend

      - name: Build admin image
        run: |
          docker build -t adpriority-admin:${{ env.VERSION }} ./admin-ui

      - name: Save images
        run: |
          docker save adpriority-backend:${{ env.VERSION }} | gzip > backend.tar.gz
          docker save adpriority-admin:${{ env.VERSION }} | gzip > admin.tar.gz

      - name: Deploy to NAS
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.NAS_HOST }}
          username: ${{ secrets.NAS_USER }}
          key: ${{ secrets.NAS_SSH_KEY }}
          source: "backend.tar.gz,admin.tar.gz"
          target: "/volume1/docker/adpriority/deploy/"

      - name: Load and restart
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.NAS_HOST }}
          username: ${{ secrets.NAS_USER }}
          key: ${{ secrets.NAS_SSH_KEY }}
          script: |
            cd /volume1/docker/adpriority
            docker load < deploy/backend.tar.gz
            docker load < deploy/admin.tar.gz
            docker-compose -f docker-compose.prod.yml up -d
            rm -f deploy/backend.tar.gz deploy/admin.tar.gz

Deployment Process

DEPLOYMENT PIPELINE
===================

  Developer pushes to main branch
         |
         v
  GitHub Actions triggered
         |
         +-- Install dependencies
         +-- Run tests (unit + integration)
         +-- Build backend Docker image
         +-- Build admin-ui Docker image
         |
         v
  If tests pass:
         |
         +-- Save images as tar.gz
         +-- SCP to NAS /volume1/docker/adpriority/deploy/
         +-- SSH: docker load images
         |
         v
  Deploy to staging first:
         |
         +-- SSH: docker-compose -f docker-compose.staging.yml up -d
         +-- Run smoke tests against staging (health, API, sync)
         |
         v
  If staging healthy:
         |
         +-- SSH: docker-compose -f docker-compose.prod.yml up -d
         +-- Health check passes
         |
         v
  Deployment complete

Rollback

If a deployment introduces issues:

# Quick rollback to previous version
cd /volume1/docker/adpriority
VERSION=v1.0.1 docker-compose -f docker-compose.prod.yml up -d

Image tags follow semantic versioning. The latest tag always points to the most recent main branch build. Tagged releases (v1.0.0, v1.0.1) are immutable and can be used for pinned rollbacks.


Database Backup

AdPriority data is backed up alongside the other databases on the postgres16 container.

# Manual backup
docker exec postgres16 pg_dump -U adpriority_user adpriority_db \
  > /volume1/docker/backups/adpriority-$(date +%Y%m%d).sql

# Automated daily backup (add to NAS cron)
0 3 * * * docker exec postgres16 pg_dump -U adpriority_user adpriority_db \
  > /volume1/docker/backups/adpriority-$(date +\%Y\%m\%d).sql

Backups are retained for 30 days with weekly archives kept for 6 months.

Offsite Backups (Backblaze B2)

Local-only backups do not survive NAS hardware failure (see ADR-015). Daily offsite backups to Backblaze B2 provide geographic separation.

# Offsite backup via rclone (runs daily at 04:00 UTC via Bull queue)
docker exec postgres16 pg_dump -U adpriority_user -Fc adpriority_db \
  > /volume1/docker/backups/adpriority-$(date +%Y%m%d).dump

# Upload to Backblaze B2
rclone copy /volume1/docker/backups/adpriority-$(date +%Y%m%d).dump \
  b2:adpriority-backups/daily/ \
  --transfers 1 --bwlimit 10M

# Retention: 30 days
rclone delete b2:adpriority-backups/daily/ --min-age 30d

Weekly verification (restore to temp database and validate):

# Download latest backup
rclone copy b2:adpriority-backups/daily/adpriority-$(date +%Y%m%d).dump /tmp/

# Restore to temp database
docker exec -i postgres16 psql -U postgres -c "CREATE DATABASE adpriority_verify;"
docker exec -i postgres16 pg_restore -U postgres -d adpriority_verify /tmp/adpriority-*.dump

# Validate row counts
docker exec postgres16 psql -U postgres -d adpriority_verify -c \
  "SELECT 'tenants' AS tbl, COUNT(*) FROM tenants
   UNION ALL SELECT 'products', COUNT(*) FROM products;"

# Cleanup
docker exec postgres16 psql -U postgres -c "DROP DATABASE adpriority_verify;"

Cost: ~$5/TB/month (Backblaze B2). AdPriority database is expected to remain under 1 GB for the first 200 tenants.


Staging Environment

A staging environment mirrors production for pre-deployment validation. It uses a separate database and Redis instance on the same NAS.

# /volume1/docker/adpriority/docker-compose.staging.yml
services:
  backend:
    image: adpriority-backend:${VERSION:-latest}
    container_name: adpriority-staging-backend
    ports:
      - "3020:3010"
    environment:
      - NODE_ENV=staging
      - DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_staging_db
      - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
      - SHOPIFY_CLIENT_ID=${STAGING_SHOPIFY_CLIENT_ID}
      - SHOPIFY_CLIENT_SECRET=${STAGING_SHOPIFY_CLIENT_SECRET}
      - ENCRYPTION_KEY_V1=${ENCRYPTION_KEY_V1}
    depends_on:
      - redis
    networks:
      - postgres_default
      - adpriority-staging
    deploy:
      resources:
        limits:
          memory: 512M

  worker:
    image: adpriority-backend:${VERSION:-latest}
    container_name: adpriority-staging-worker
    command: ["npm", "run", "worker"]
    environment:
      - NODE_ENV=staging
      - DATABASE_URL=postgresql://adpriority_user:${DB_PASSWORD}@postgres16:5432/adpriority_staging_db
      - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
      - ENCRYPTION_KEY_V1=${ENCRYPTION_KEY_V1}
    depends_on:
      - redis
    networks:
      - postgres_default
      - adpriority-staging
    deploy:
      resources:
        limits:
          memory: 512M

  redis:
    image: redis:7-alpine
    container_name: adpriority-staging-redis
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"]
    networks:
      - adpriority-staging
    deploy:
      resources:
        limits:
          memory: 128M

networks:
  postgres_default:
    external: true
  adpriority-staging:
    driver: bridge

Staging database setup:

docker exec -it postgres16 psql -U postgres << EOF
CREATE DATABASE adpriority_staging_db;
GRANT ALL PRIVILEGES ON DATABASE adpriority_staging_db TO adpriority_user;
EOF

CSP Headers

Content Security Policy headers are required for Shopify embedded apps. The Express backend sets these on every response:

// Middleware: CSP headers for Shopify embedded app
app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    "frame-ancestors https://*.myshopify.com https://admin.shopify.com; " +
    "script-src 'self' https://cdn.shopify.com; " +
    "style-src 'self' 'unsafe-inline' https://cdn.shopify.com; " +
    "img-src 'self' data: https://cdn.shopify.com https://*.googleusercontent.com; " +
    "connect-src 'self' https://*.myshopify.com https://*.googleapis.com"
  );
  next();
});
DirectiveValueReason
frame-ancestorshttps://*.myshopify.com https://admin.shopify.comAllow embedding only in Shopify Admin
script-src'self' https://cdn.shopify.comApp scripts + Shopify App Bridge
style-src'self' 'unsafe-inline' https://cdn.shopify.comPolaris styles
img-src'self' data: https://cdn.shopify.comProduct images, Polaris icons
connect-src'self' https://*.myshopify.com https://*.googleapis.comAPI calls

Cloud Migration Triggers

The NAS deployment is the starting architecture. The following metrics indicate when migration to cloud infrastructure should be evaluated:

MetricThresholdCloud Action
Active tenants> 100Evaluate managed PostgreSQL (RDS/Supabase)
API p95 latency> 500ms sustainedEvaluate cloud compute (Railway, Render)
Monthly backup size> 10 GBEvaluate managed backup service
Revenue (MRR)> $5,000/moCloud costs justified; begin migration planning
SLA requirement> 99.0% demanded by customersNAS cannot guarantee; must migrate
NAS CPU sustained> 80% for 1 hourResource ceiling reached
Webhook processing delay> 60 secondsWorker needs horizontal scaling

Migration readiness checklist:

  • Docker images are portable (no NAS-specific dependencies)
  • Backblaze B2 backups use S3-compatible API (works with AWS S3, GCS)
  • Environment variables externalize all configuration
  • Cloudflare Tunnel can be replaced with cloud load balancer
  • Bull+Redis can migrate to managed Redis (ElastiCache, Upstash)