Docker Production Build Pipeline
Problem: Build a custom Docker image containing ScoopJoy’s custom apps and deploy it to production using a reliable build and deployment pipeline.
Solution: Establish a reproducible container build environment and automated deployment scripts. The pipeline uses a multi-stage Docker build to keep production images lightweight, a Docker Compose stack to scale services, a deployment shell script to achieve zero-downtime rolling updates, and a Makefile to simplify daily administration tasks.
Project Layout
Section titled “Project Layout”Here is the directory structure for your Docker build setup:
- apps.json List of custom apps to build into the image
- Containerfile Multi-stage Docker build file
- docker-compose.yml Services stack (backend, workers, scheduler, web, database, caching)
- .env.template Environment template file
- Makefile Command runner for developer operations
Directoryscripts/
- build_and_deploy.sh Rolling update build and deployment script
-
Define custom apps (
apps.json)Create
apps.jsonin the root of your build directory to define which apps will be pulled from Git and installed into the Frappe bench. This includes core Frappe/ERPNext repositories as well as custom apps (e.g.,scoopjoy-erp).apps.json [{"url": "https://github.com/frappe/erpnext","branch": "version-16"},{"url": "https://github.com/frappe/hrms","branch": "version-16"},{"url": "https://github.com/scoopjoy/scoopjoy-erp.git","branch": "main"}] -
Configure multi-stage build (
Containerfile)The
Containerfileexecutes a multi-stage Docker build. Thebuilderstage resolves Python/Node dependencies and compiles frontend assets in a temporaryfrappe/benchcontainer. Theproductionstage starts with a clean runtime base (frappe/frappe) and copies only the compiled assets, dependencies, and apps from the builder, ensuring a minimal final image.Containerfile # Based on frappe_docker/images/custom/Containerfile# Ref: https://github.com/frappe/frappe_docker/blob/main/images/custom/ContainerfileARG FRAPPE_VERSION=16.0ARG PYTHON_VERSION=3.12ARG NODE_VERSION=20# ============================================# Stage 1: Build assets and install apps# ============================================FROM frappe/bench:latest AS builderARG FRAPPE_VERSIONARG APPS_JSON_BASE64USER frappeWORKDIR /home/frappe/frappe-bench# Initialize bench with FrappeRUN bench init \--frappe-branch version-${FRAPPE_VERSION} \--skip-redis-config-generation \--no-procfile \--no-backups \--skip-assets \--verbose \/home/frappe/frappe-bench# Install apps from apps.jsonRUN if [ -n "$APPS_JSON_BASE64" ]; then \echo "$APPS_JSON_BASE64" | base64 -d > /home/frappe/frappe-bench/apps.json; \fiCOPY apps.json /home/frappe/frappe-bench/apps.json 2>/dev/null || trueRUN bench setup requirements --dev \&& cat apps.json | python -c "import sys, jsonapps = json.load(sys.stdin)for app in apps:print(app['url'], app.get('branch', 'main'))" | while read url branch; do \app_name=\$(basename \$url .git); \if [ "\$app_name" != "frappe" ]; then \bench get-app --branch \$branch --skip-assets \$url; \fi; \done# Build frontend assetsRUN bench build --production# ============================================# Stage 2: Production runtime image# ============================================FROM frappe/frappe:v${FRAPPE_VERSION} AS production# Copy built apps and assets from builderCOPY --from=builder /home/frappe/frappe-bench/apps /home/frappe/frappe-bench/appsCOPY --from=builder /home/frappe/frappe-bench/sites/assets /home/frappe/frappe-bench/sites/assetsCOPY --from=builder /home/frappe/frappe-bench/env /home/frappe/frappe-bench/env# Custom: copy nginx template overrides if needed# COPY resources/nginx-template.conf /templates/nginx/frappe.conf.templateUSER frappeWORKDIR /home/frappe/frappe-bench# Health checkHEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \CMD curl -fsSL http://localhost:8000/api/method/ping || exit 1EXPOSE 8000 -
Define production services (
docker-compose.yml)Define the complete production topology in
docker-compose.yml. This splits the single-bench environment into decoupled containers for the web server, background workers (separated by queue priority), scheduler, websocket server, database, and Redis instances.docker-compose.yml # Production deployment for ScoopJoy ERPx-common-env: &common-envDB_HOST: mariadbDB_PORT: "3306"REDIS_CACHE: redis-cache:6379REDIS_QUEUE: redis-queue:6379SOCKETIO_PORT: "9000"x-backend-defaults: &backend-defaultsimage: ${SCOOPJOY_IMAGE:-ghcr.io/scoopjoy/erp:latest}restart: unless-stoppedenv_file:- .envenvironment:<<: *common-envvolumes:- sites-vol:/home/frappe/frappe-bench/sites- logs-vol:/home/frappe/frappe-bench/logsservices:# === Application Services ===backend:<<: *backend-defaultscommand: >bench serve--port 8000--with-request-loggingdeploy:replicas: 2resources:limits:memory: 1Gcpus: "1.0"reservations:memory: 512Mwebsocket:<<: *backend-defaultscommand: node /home/frappe/frappe-bench/apps/frappe/socketio.jsdeploy:resources:limits:memory: 256Mqueue-default:<<: *backend-defaultscommand: bench worker --queue defaultqueue-short:<<: *backend-defaultscommand: bench worker --queue shortqueue-long:<<: *backend-defaultscommand: bench worker --queue longscheduler:<<: *backend-defaultscommand: bench schedule# === Infrastructure Services ===frontend:image: frappe/frappe-nginx:v16restart: unless-stoppeddepends_on:- backend- websocketports:- "${HTTP_PORT:-8080}:8080"volumes:- sites-vol:/home/frappe/frappe-bench/sites:roenvironment:BACKEND: backend:8000SOCKETIO: websocket:9000UPSTREAM_REAL_IP_ADDRESS: 127.0.0.1UPSTREAM_REAL_IP_HEADER: X-Forwarded-ForUPSTREAM_REAL_IP_RECURSIVE: "on"PROXY_READ_TIMEOUT: "120"CLIENT_MAX_BODY_SIZE: "50m"mariadb:image: mariadb:11.4restart: unless-stoppedcommand:- --character-set-server=utf8mb4- --collation-server=utf8mb4_unicode_ci- --skip-character-set-client-handshake- --skip-innodb-read-only-compressed- --innodb-buffer-pool-size=2G- --innodb-log-file-size=256M- --innodb-flush-log-at-trx-commit=2- --max-connections=200- --slow-query-log=1- --slow-query-log-file=/var/log/mysql/slow.log- --long-query-time=2environment:MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_passwordvolumes:- mariadb-vol:/var/lib/mysql- mariadb-logs:/var/log/mysqlsecrets:- db_root_passwordhealthcheck:test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "--password=$$MYSQL_ROOT_PASSWORD"]interval: 10stimeout: 5sretries: 5redis-cache:image: redis:7-alpinerestart: unless-stoppedcommand: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --save ""volumes:- redis-cache-vol:/datahealthcheck:test: ["CMD", "redis-cli", "ping"]interval: 10sredis-queue:image: redis:7-alpinerestart: unless-stoppedcommand: redis-server --maxmemory 256mb --save 60 1000volumes:- redis-queue-vol:/datahealthcheck:test: ["CMD", "redis-cli", "ping"]interval: 10s# === Volumes ===volumes:sites-vol:logs-vol:mariadb-vol:mariadb-logs:redis-cache-vol:redis-queue-vol:# === Secrets ===secrets:db_root_password:file: ./secrets/db_root_password.txt -
Template environment variables (
.env.template)Provide a template for environment variables (
.env.template) specifying database connections, email setup, backups, and app-specific integrations. Copy this to.envon the host and customize the values (never check.envinto version control)..env.template # Copy to .env and fill in values. NEVER commit .env to git.# === Image ===SCOOPJOY_IMAGE=ghcr.io/scoopjoy/erp:latest# === Networking ===HTTP_PORT=8080# === Frappe Site ===FRAPPE_SITE_NAME_HEADER=erp.scoopjoy.com# === Database ===DB_HOST=mariadbDB_PORT=3306# === Redis ===REDIS_CACHE=redis-cache:6379REDIS_QUEUE=redis-queue:6379# === Email ===MAIL_SERVER=smtp.gmail.comMAIL_PORT=587MAIL_LOGIN=erp-notifications@scoopjoy.comMAIL_PASSWORD=changemeMAIL_USE_TLS=1# === Backup ===SCOOPJOY_BACKUP_BUCKET=scoopjoy-backupsSCOOPJOY_BACKUP_REGION=ap-south-1AWS_ACCESS_KEY_ID=changemeAWS_SECRET_ACCESS_KEY=changeme# === App Secrets ===SCOOPJOY_RAZORPAY_KEY_ID=rzp_live_changemeSCOOPJOY_RAZORPAY_KEY_SECRET=changeme -
Orchestrate deployment (
scripts/build_and_deploy.sh)Automate the compilation and rolling deployment process using
scripts/build_and_deploy.sh. The deployment logic ensures zero-downtime updates by scaling the backend container (using--scale backend=2), performing rolling container updates, checking health status before proceeding, running migrations viabench migrate, and clearing cache.scripts/build_and_deploy.sh #!/bin/bashset -euo pipefail# ConfigurationREGISTRY="ghcr.io/scoopjoy"IMAGE_NAME="erp"COMPOSE_FILE="docker-compose.yml"DEPLOY_DIR="/opt/scoopjoy"# Parse argumentsACTION="${1:-build}"TAG="${2:-$(git rev-parse --short HEAD)}"FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}"log() { echo "[$(date '+%H:%M:%S')] $*"; }build() {log "Building image ${FULL_IMAGE}:${TAG}"# Encode apps.json as base64APPS_JSON_BASE64=$(base64 -w 0 apps.json)docker build \--build-arg FRAPPE_VERSION=16.0 \--build-arg APPS_JSON_BASE64="${APPS_JSON_BASE64}" \--tag "${FULL_IMAGE}:${TAG}" \--tag "${FULL_IMAGE}:latest" \--file Containerfile \.log "Build complete: ${FULL_IMAGE}:${TAG}"}push() {log "Pushing ${FULL_IMAGE}:${TAG} and :latest"docker push "${FULL_IMAGE}:${TAG}"docker push "${FULL_IMAGE}:latest"log "Push complete"}deploy() {log "Deploying ${FULL_IMAGE}:${TAG} to production"# Save current image tag for rollbackif [ -f "${DEPLOY_DIR}/.current-tag" ]; thencp "${DEPLOY_DIR}/.current-tag" "${DEPLOY_DIR}/.previous-tag"fiecho "${TAG}" > "${DEPLOY_DIR}/.current-tag"cd "${DEPLOY_DIR}"# Pull new imageexport SCOOPJOY_IMAGE="${FULL_IMAGE}:${TAG}"# Zero-downtime rolling update# Update backend workers first (no traffic impact)docker compose up -d --no-deps queue-default queue-short queue-long schedulerlog "Workers updated"# Update backend with rolling restart (2 replicas means zero downtime)docker compose up -d --no-deps --scale backend=2 backendlog "Backend updated"# Wait for health checkssleep 10docker compose ps backend | grep -q "healthy" || {log "ERROR: Backend health check failed"rollbackexit 1}# Update websocket and frontenddocker compose up -d --no-deps websocket frontendlog "Frontend updated"# Run migrationsdocker compose exec backend bench --site erp.scoopjoy.com migratelog "Migrations complete"# Clear cachedocker compose exec backend bench --site erp.scoopjoy.com clear-cachelog "Cache cleared"log "Deployment complete: ${TAG}"}rollback() {log "Rolling back to previous version"cd "${DEPLOY_DIR}"if [ ! -f "${DEPLOY_DIR}/.previous-tag" ]; thenlog "ERROR: No previous tag found for rollback"exit 1fiPREV_TAG=$(cat "${DEPLOY_DIR}/.previous-tag")export SCOOPJOY_IMAGE="${FULL_IMAGE}:${PREV_TAG}"docker compose up -dlog "Rolled back to ${PREV_TAG}"# Swap tagsecho "${PREV_TAG}" > "${DEPLOY_DIR}/.current-tag"}case "${ACTION}" inbuild) build ;;push) push ;;deploy) deploy ;;rollback) rollback ;;all) build && push && deploy ;;*)echo "Usage: $0 {build|push|deploy|rollback|all} [tag]"exit 1;;esac -
Maintain stack using
MakefileUse a
Makefileto provide simple, standardized developer shortcuts for everyday production administration tasks like database backups, restoring from S3, tailing logs, running test suites, or checking service health.Makefile # ScoopJoy ERP Docker Build PipelineREGISTRY := ghcr.io/scoopjoyIMAGE := erpTAG ?= $(shell git rev-parse --short HEAD)FULL_IMAGE := $(REGISTRY)/$(IMAGE)COMPOSE := docker composeSITE := erp.scoopjoy.comDEPLOY_DIR := /opt/scoopjoy.PHONY: help build push deploy rollback shell logs backup restore test cleanhelp: ## Show this help@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'# === Build ===build: ## Build Docker image (TAG=<git-sha>)@echo "Building $(FULL_IMAGE):$(TAG)"docker build \--build-arg FRAPPE_VERSION=16.0 \--build-arg APPS_JSON_BASE64=$$(base64 -w 0 apps.json) \--tag $(FULL_IMAGE):$(TAG) \--tag $(FULL_IMAGE):latest \--file Containerfile .push: ## Push image to registrydocker push $(FULL_IMAGE):$(TAG)docker push $(FULL_IMAGE):latest# === Deploy ===deploy: ## Deploy to production with zero-downtime@bash scripts/build_and_deploy.sh deploy $(TAG)rollback: ## Rollback to previous version@bash scripts/build_and_deploy.sh rollback# === Operations ===shell: ## Open shell in backend container$(COMPOSE) exec backend bashconsole: ## Open Frappe console$(COMPOSE) exec backend bench --site $(SITE) consolelogs: ## Tail all service logs$(COMPOSE) logs -f --tail=100logs-backend: ## Tail backend logs only$(COMPOSE) logs -f --tail=100 backendmigrate: ## Run bench migrate$(COMPOSE) exec backend bench --site $(SITE) migrateclear-cache: ## Clear Frappe cache$(COMPOSE) exec backend bench --site $(SITE) clear-cache# === Backup ===backup: ## Run backup and upload to S3$(COMPOSE) exec backend python3 scripts/backup_to_s3.py --site $(SITE)restore: ## Restore from S3 (DATE=YYYY-MM-DD required)@test -n "$(DATE)" || (echo "Usage: make restore DATE=2026-03-20" && exit 1)$(COMPOSE) exec backend python3 scripts/restore_from_s3.py --site $(SITE) --date $(DATE)# === Testing ===test: ## Run all tests$(COMPOSE) exec backend bench --site test_site run-tests --app scoopjoytest-unit: ## Run unit tests only$(COMPOSE) exec backend bench --site test_site run-tests --app scoopjoy -k "Unit"test-doctype: ## Run tests for a specific doctype (DT="Franchise Outlet")@test -n "$(DT)" || (echo "Usage: make test-doctype DT='Franchise Outlet'" && exit 1)$(COMPOSE) exec backend bench --site test_site run-tests --doctype "$(DT)"# === Maintenance ===up: ## Start all services$(COMPOSE) up -ddown: ## Stop all services$(COMPOSE) downrestart: ## Restart all services$(COMPOSE) restartps: ## Show running services$(COMPOSE) pshealth: ## Check health of all services@$(COMPOSE) ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"clean: ## Remove stopped containers and dangling imagesdocker system prune -fdocker image prune -f