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
| Container | Image | Role | Network |
|---|---|---|---|
adpriority-backend | adpriority-backend | Express API server, handles HTTP requests, OAuth, webhooks | postgres_default + adpriority |
adpriority-admin | adpriority-admin | Serves React/Polaris frontend (Nginx in production) | adpriority |
adpriority-worker | adpriority-backend (same image, different entrypoint) | Processes Bull queue jobs: syncs, seasonal transitions, bulk operations | postgres_default + adpriority |
adpriority-redis | redis:7-alpine | Job queue backend, session cache | adpriority |
postgres16 | Shared (external) | PostgreSQL 16, database: adpriority_db | postgres_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:
| Hostname | Service | Purpose |
|---|---|---|
adpriority.nexusclothing.synology.me | http://localhost:3010 | API, auth, webhooks |
adpriority.nexusclothing.synology.me (path: /*) | http://localhost:3011 | Admin 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
| Variable | Description | Example |
|---|---|---|
SHOPIFY_CLIENT_ID | App client ID from Partner Dashboard | abc123def456 |
SHOPIFY_CLIENT_SECRET | App client secret (never log this) | shpss_xxxxxxxx |
SHOPIFY_SCOPES | OAuth scopes | read_products,write_products,read_inventory |
DATABASE_URL | PostgreSQL connection string | postgresql://adpriority_user:pass@postgres16:5432/adpriority_db |
DB_PASSWORD | Database password (referenced in DATABASE_URL) | AdPrioritySecure2026 |
REDIS_URL | Redis connection string | redis://:${REDIS_PASSWORD}@redis:6379 |
REDIS_PASSWORD | Redis authentication password | Random 32-character string |
HOST | Public HTTPS URL (for OAuth redirects) | https://adpriority.nexusclothing.synology.me |
APP_DOMAIN | Domain without protocol | adpriority.nexusclothing.synology.me |
ENCRYPTION_KEY_V1 | AES-256 key for token encryption (versioned, see ADR-011) | 32-byte hex string |
GOOGLE_SHEETS_CREDENTIALS | Service account JSON (base64 encoded) | Base64 string |
Optional Variables
| Variable | Description | Default |
|---|---|---|
PORT | Backend server port | 3010 |
NODE_ENV | Runtime environment | development |
LOG_LEVEL | Logging verbosity | info |
SYNC_FREQUENCY_MINUTES | Default sync interval | 360 (6 hours) |
NEW_ARRIVAL_DAYS | Days to consider a product “new” | 14 |
SENTRY_DSN | Error 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();
});
| Directive | Value | Reason |
|---|---|---|
frame-ancestors | https://*.myshopify.com https://admin.shopify.com | Allow embedding only in Shopify Admin |
script-src | 'self' https://cdn.shopify.com | App scripts + Shopify App Bridge |
style-src | 'self' 'unsafe-inline' https://cdn.shopify.com | Polaris styles |
img-src | 'self' data: https://cdn.shopify.com | Product images, Polaris icons |
connect-src | 'self' https://*.myshopify.com https://*.googleapis.com | API calls |
Cloud Migration Triggers
The NAS deployment is the starting architecture. The following metrics indicate when migration to cloud infrastructure should be evaluated:
| Metric | Threshold | Cloud Action |
|---|---|---|
| Active tenants | > 100 | Evaluate managed PostgreSQL (RDS/Supabase) |
| API p95 latency | > 500ms sustained | Evaluate cloud compute (Railway, Render) |
| Monthly backup size | > 10 GB | Evaluate managed backup service |
| Revenue (MRR) | > $5,000/mo | Cloud costs justified; begin migration planning |
| SLA requirement | > 99.0% demanded by customers | NAS cannot guarantee; must migrate |
| NAS CPU sustained | > 80% for 1 hour | Resource ceiling reached |
| Webhook processing delay | > 60 seconds | Worker 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)