Build, Deploy & Release Recipe
A batteries-included blueprint for monorepo infrastructure: modular Makefiles, Docker organization, semantic versioning, Cloudflare deployment, and Terraform IaC. Use this to bootstrap any project with production-grade patterns.
Table of Contents
- Project Structure Overview
- Makefile Module System
- Docker Organization
- Scripts Architecture
- Release & Version Bumping
- Cloudflare Workers & Containers
- Terraform Infrastructure
- Complete Bootstrap Checklist
Project Structure Overview
project-root/├── Makefile # Root loader - includes all make/*.mk modules├── make/ # Modular Makefile targets (one per concern)│ ├── app.mk # App build/run targets│ ├── cloudflare.mk # CF Workers deployment│ ├── dev.mk # Development server│ ├── gpu-infra.mk # Terraform GPU infrastructure│ ├── rag.mk # RAG system targets│ ├── release.mk # Version bumping & releases│ └── whitepaper.mk # Documentation compilation│├── apps/ # Application packages (monorepo style)│ ├── main-app/│ │ ├── package.json # Version source of truth│ │ ├── CHANGELOG.md # Auto-generated by standard-version│ │ └── ...│ └── admin-app/│├── brain/ # Internal tooling packages│ └── rag/ # RAG system with its own package.json│├── infra/ # All infrastructure code│ ├── dockerfiles/ # Docker images (one folder per image)│ │ ├── main-app/│ │ │ ├── Dockerfile│ │ │ └── base.dockerfile # Alternative/specialized Dockerfile│ │ └── sidecar-service/│ │ ├── Dockerfile│ │ ├── package.json # Self-contained buildable package│ │ ├── src/│ │ └── tsconfig.json│ ││ ├── cloudflare/ # Cloudflare Workers (one folder per worker)│ │ ├── containers-worker/ # Main app container orchestration│ │ │ ├── wrangler.toml│ │ │ ├── package.json│ │ │ └── src/index.ts│ │ ├── api-worker/ # API endpoints│ │ ├── websocket-relay/ # WebSocket Durable Objects│ │ └── README.md│ ││ ├── compose/ # Docker Compose files for local/atomic services│ │ └── app.yml│ ││ └── terraform/ # Terraform modules│ └── gpu-node/│ ├── main.tf│ ├── variables.tf│ ├── outputs.tf│ ├── versions.tf│ ├── cloud-init.yaml.tpl│ ├── env.example│ └── terraform.tfvars.example│├── scripts/ # Shell scripts & tooling│ ├── app-version.mjs # Extract version from package.json│ ├── release-sync-versions.mjs # Sync versions across manifests│ ├── simple-release.sh # Fallback release script│ ├── setup-gpu-infra.sh # Interactive setup wizard│ ├── validate-gpu-infra.sh # Infrastructure validation│ ││ ├── rag-*.sh # RAG command wrappers (thin shells)│ ││ └── whitepaper/ # Documentation compiler (self-contained)│ ├── package.json│ ├── compile.ts│ ├── md2pdf.ts│ ├── style.css│ └── template.latex│├── docs/ # Documentation├── artifacts/ # Build outputs (gitignored)│├── release-please-config.json # Optional: Google release-please├── .release-please-manifest.json└── package.json # Root package (minimal - workspace deps only)Key Organization Principles
- One folder per concern - Each Dockerfile, Worker, Terraform module gets its own folder
- Self-contained builds -
infra/dockerfiles/*/can include source code for buildable sidecars - Scripts as thin wrappers - Shell scripts delegate to actual implementations
- Infrastructure isolation - All infra code under
infra/, never mixed with app code
Makefile Module System
Root Makefile Pattern
# Makefile.SILENT:SHELL := /bin/bash.SHELLFLAGS := -eu -o pipefail -cROOT_DIR := $(abspath $(CURDIR))APP_DIR := $(ROOT_DIR)/apps/main-appMAKEFILES := $(wildcard make/*.mk)
.PHONY: helphelp: ## Show all available targets @if [ -z "$(MAKEFILES)" ]; then \ printf "No module targets loaded yet.\n"; \ else \ printf "Available targets:\n"; \ grep -hE '^[a-zA-Z0-9_.-]+:.*##' $(MAKEFILES) | awk 'BEGIN {FS = ":.*##"}; {printf " %-25s %s\n", $$1, $$2}'; \ fi
# Utility: Generate secretsBYTES ?= 32FORMAT ?= hex
.PHONY: make-keymake-key: ## Generate a secret key (BYTES=32, FORMAT=hex|base64|uuid) @case "$(FORMAT)" in \ hex) openssl rand -hex $(BYTES) ;; \ base64) openssl rand -base64 $(BYTES) ;; \ uuid) uuidgen | tr '[:upper:]' '[:lower:]' ;; \ *) echo "Unknown format: $(FORMAT)" && exit 1 ;; \ esac
# Auto-include all modulesifneq ($(strip $(MAKEFILES)),)include $(MAKEFILES)endifModule Pattern
Each make/*.mk file follows this pattern:
# Module-specific path variablesEXAMPLE_DIR := $(ROOT_DIR)/path/to/componentEXAMPLE_SCRIPTS := $(ROOT_DIR)/scripts
# Targets with ## for auto-documentation.PHONY: example-buildexample-build: ## Build the example component @echo "Building..." @cd $(EXAMPLE_DIR) && ./build.sh
.PHONY: example-deployexample-deploy: example-build ## Deploy (chains to build first) @echo "Deploying..." @cd $(EXAMPLE_DIR) && ./deploy.sh
# Pass-through arguments patternEXAMPLE_ARGS := $(strip $(ARGS))
.PHONY: example-runexample-run: ## Run with arguments (ARGS="--flag value") $(EXAMPLE_SCRIPTS)/example-run.sh $(EXAMPLE_ARGS)Conventions
| Convention | Example |
|---|---|
Every target has ## Description | build: ## Build the app |
Use @ prefix for silent execution | @echo "message" |
| Chain dependencies left-to-right | deploy: build test |
Use $(ROOT_DIR) for absolute paths | cd $(ROOT_DIR)/apps/... |
Pass arguments via ARGS variable | make target ARGS="--flag" |
| Group related targets with comments | # ═══ Section ═══ |
Docker Organization
Folder Structure Philosophy
infra/dockerfiles/├── main-app/ # App container│ ├── Dockerfile # Production multi-stage build│ └── base.dockerfile # Alternative (e.g., for different base image)│└── sidecar-service/ # Self-contained service ├── Dockerfile ├── package.json # Its own dependencies ├── tsconfig.json └── src/ └── index.ts # Source lives HERE, not in apps/Why this structure?
- Docker COPY paths are clean and predictable
- Each image is self-contained (no cross-folder dependencies)
- Sidecars can have their own package.json without polluting main app
- Build context is minimal (just that folder)
Multi-Stage Dockerfile Pattern
# infra/dockerfiles/main-app/DockerfileARG NODE_IMAGE=node:20-slimARG APP_VERSION="0.0.0"
# ─────────────────────────────────────────────────────────────────# Base: Shared settings# ─────────────────────────────────────────────────────────────────FROM ${NODE_IMAGE} AS baseWORKDIR /appENV NODE_ENV=productionRUN corepack enable
# ─────────────────────────────────────────────────────────────────# Deps: Install all dependencies# ─────────────────────────────────────────────────────────────────FROM base AS depsENV NODE_ENV=developmentCOPY package.json yarn.lock ./RUN --mount=type=cache,target=/root/.cache/yarn \ yarn install --frozen-lockfile --non-interactive
# ─────────────────────────────────────────────────────────────────# Builder: Build the application# ─────────────────────────────────────────────────────────────────FROM base AS builderARG APP_VERSIONENV PUBLIC_APP_VERSION=${APP_VERSION}COPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN yarn build && yarn cache clean
# ─────────────────────────────────────────────────────────────────# Prod-deps: Production dependencies only# ─────────────────────────────────────────────────────────────────FROM deps AS prod-depsENV NODE_ENV=productionRUN --mount=type=cache,target=/root/.cache/yarn \ yarn install --frozen-lockfile --production --prefer-offline
# ─────────────────────────────────────────────────────────────────# Runner: Final minimal image# ─────────────────────────────────────────────────────────────────FROM ${NODE_IMAGE} AS runnerWORKDIR /appENV NODE_ENV=production \ HOST=0.0.0.0 \ PORT=8080
COPY package.json yarn.lock ./COPY --from=prod-deps /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./dist
EXPOSE 8080USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ CMD node -e "fetch('http://127.0.0.1:8080/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1));"
CMD ["node", "./dist/server/entry.mjs"]Sidecar Dockerfile Pattern (Alpine, Self-Contained)
# infra/dockerfiles/sidecar-service/DockerfileFROM node:20-alpine AS builderWORKDIR /app
COPY package*.json ./RUN npm ci
COPY src ./srcCOPY tsconfig.json ./RUN npm run buildRUN npm ci --omit=dev
# ─────────────────────────────────────────────────────────────────FROM node:20-alpineWORKDIR /app
# Security: Non-root userRUN addgroup -g 1001 -S appuser && \ adduser -S appuser -u 1001 -G appuser
COPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/package.json ./
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ CMD wget -q --spider http://localhost:8080/health || exit 1
USER appuserEXPOSE 8080CMD ["node", "dist/index.js"]Docker Compose for Development
version: '3.8'
services: main-app: build: context: ../dockerfiles/main-app dockerfile: Dockerfile ports: - "8080:8080" environment: - NODE_ENV=development volumes: - ../../apps/main-app/src:/app/src:ro healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8080/_health"] interval: 30s timeout: 10s retries: 3
sidecar: build: context: ../dockerfiles/sidecar-service ports: - "8081:8080" environment: - MAIN_APP_URL=http://main-app:8080 depends_on: main-app: condition: service_healthyScripts Architecture
Pattern: Thin Shell Wrappers
Scripts in the root scripts/ folder are thin wrappers that delegate to actual implementations:
#!/usr/bin/env bashset -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"yarn --cwd "$ROOT_DIR/brain/rag" rag:query "$@"Why?
- Makefile targets call these scripts
- Scripts handle path resolution
- Actual implementation lives in the package (brain/rag)
- Easy to call from anywhere
Script Categories
| Category | Pattern | Example |
|---|---|---|
| Wrappers | Delegate to package scripts | rag-*.sh |
| Utilities | Standalone tools | app-version.mjs |
| Setup wizards | Interactive configuration | setup-gpu-infra.sh |
| Validation | Pre-flight checks | validate-gpu-infra.sh |
| Subsystems | Self-contained tooling | whitepaper/ folder |
Version Extraction Script
#!/usr/bin/env nodeimport { readFileSync } from 'node:fs';import { fileURLToPath } from 'node:url';import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));const pkgPath = join(__dirname, '..', 'apps', 'main-app', 'package.json');const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
if (!pkg.version) { console.error('Unable to read version'); process.exit(1);}
process.stdout.write(pkg.version);Setup Wizard Pattern
#!/usr/bin/env bashset -euo pipefail
# ColorsRED='\033[0;31m'GREEN='\033[0;32m'YELLOW='\033[1;33m'CYAN='\033[0;36m'NC='\033[0m'BOLD='\033[1m'
# PathsSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Helpersprint_header() { echo -e "\n${BOLD}${CYAN}═══ $1 ═══${NC}\n"; }print_step() { echo -e "${GREEN}▶${NC} $1"; }print_success() { echo -e "${GREEN}✓${NC} $1"; }print_warning() { echo -e "${YELLOW}⚠${NC} $1"; }print_error() { echo -e "${RED}✗${NC} $1"; }
check_command() { if command -v "$1" &> /dev/null; then print_success "$1 is installed" return 0 else print_error "$1 is not installed" return 1 fi}
# Mainmain() { print_header "Setup Wizard"
# Step 1: Check prerequisites print_header "Checking Prerequisites" check_command "docker" || exit 1 check_command "terraform" || exit 1
# Step 2: Collect input print_header "Configuration" read -p "Enter API token: " -s API_TOKEN echo
# Step 3: Write config files print_header "Writing Configuration" cat > "$PROJECT_ROOT/infra/terraform/.env" << EOFAPI_TOKEN=$API_TOKENEOF print_success "Created .env file"
# Summary print_header "Setup Complete" echo "Next steps:" echo " 1. make terraform-init" echo " 2. make terraform-plan"}
main "$@"Validation Script Pattern
#!/usr/bin/env bashset -euo pipefail
PASSED=0FAILED=0WARNINGS=0
check_pass() { echo -e "✓ $1"; PASSED=$((PASSED + 1)); }check_fail() { echo -e "✗ $1"; FAILED=$((FAILED + 1)); }check_warn() { echo -e "⚠ $1"; WARNINGS=$((WARNINGS + 1)); }
# Run checks[[ -f "config.json" ]] && check_pass "Config exists" || check_fail "Config missing"command -v docker &>/dev/null && check_pass "Docker installed" || check_fail "Docker not found"
# Summaryecho ""echo "Passed: $PASSED | Failed: $FAILED | Warnings: $WARNINGS"[[ $FAILED -eq 0 ]] && exit 0 || exit 1Release & Version Bumping
Strategy: standard-version + Make Targets
The release workflow uses standard-version for:
- Automatic version bumping based on conventional commits
- CHANGELOG.md generation
- Git tagging
Version source of truth: apps/main-app/package.json
Release Makefile Module
RELEASE_SYNC_SCRIPT := $(ROOT_DIR)/scripts/release-sync-versions.mjsAPP_DIR := $(ROOT_DIR)/apps/main-app
# Post-release hook: sync versions across manifestsdefine post_release @echo "📋 Syncing versions across manifest files..." @node $(RELEASE_SYNC_SCRIPT) 2>/dev/null || true @echo "📄 Copying CHANGELOG.md to public folder..." @cp $(APP_DIR)/CHANGELOG.md $(APP_DIR)/public/CHANGELOG.md 2>/dev/null || true @git add -A 2>/dev/null || true @git commit --amend --no-edit 2>/dev/null || trueendef
# ─────────────────────────────────────────────────────────────────# Main Release Commands# ─────────────────────────────────────────────────────────────────
.PHONY: releaserelease: ## Create release (auto-determines version from commits) @echo "🚀 Creating release..." @cd $(APP_DIR) && npx standard-version $(post_release) @git push --follow-tags origin main @echo "✅ Release pushed!"
.PHONY: release-patchrelease-patch: ## Force patch release (x.y.Z) @cd $(APP_DIR) && npx standard-version --release-as patch $(post_release)
.PHONY: release-minorrelease-minor: ## Force minor release (x.Y.0) @cd $(APP_DIR) && npx standard-version --release-as minor $(post_release)
.PHONY: release-majorrelease-major: ## Force major release (X.0.0) @cd $(APP_DIR) && npx standard-version --release-as major $(post_release)
.PHONY: release-dryrelease-dry: ## Preview release (no changes) @cd $(APP_DIR) && npx standard-version --dry-run
.PHONY: release-firstrelease-first: ## Create first release (when starting fresh) @cd $(APP_DIR) && npx standard-version --first-release $(post_release)
# ─────────────────────────────────────────────────────────────────# Combined Commands# ─────────────────────────────────────────────────────────────────
.PHONY: release-pushrelease-push: ## Push release commit and tags @git push --follow-tags origin main
.PHONY: release-fullrelease-full: release ## Alias (release already pushes)Version Sync Script
Keeps versions in sync across:
apps/main-app/package.json.release-please-manifest.json- Any other manifests
#!/usr/bin/env nodeimport { readFileSync, writeFileSync, existsSync } from 'node:fs';import { join, dirname } from 'node:path';import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));const ROOT = join(__dirname, '..');
// Files to syncconst PACKAGE_JSON = join(ROOT, 'apps/main-app/package.json');const MANIFEST = join(ROOT, '.release-please-manifest.json');
// Get current versionconst pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8'));const version = pkg.version;
console.log(`[sync] Version: ${version}`);
// Update manifestif (existsSync(MANIFEST)) { const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')); manifest['apps/main-app'] = version; writeFileSync(MANIFEST, JSON.stringify(manifest, null, 2) + '\n'); console.log(`[sync] Updated ${MANIFEST}`);}Fallback: Simple Release Script
For when you need to bypass standard-version:
#!/bin/bashset -e
RELEASE_TYPE=${1:-patch}APP_PATH="apps/main-app"
cd "$APP_PATH"CURRENT=$(node -p "require('./package.json').version")echo "Current: $CURRENT"
npm version $RELEASE_TYPE --no-git-tag-versionNEW=$(node -p "require('./package.json').version")echo "New: $NEW"
cd ../..git add "$APP_PATH/package.json"git commit -m "chore: bump to v$NEW"
TAG="v$NEW"git tag -a "$TAG" -m "Release $TAG"git push origin HEADgit push origin "$TAG"
echo "✅ Released $TAG"Conventional Commits Quick Reference
| Prefix | Version Bump | Example |
|---|---|---|
feat: | Minor | feat: add dark mode |
fix: | Patch | fix: resolve crash |
feat!: or BREAKING CHANGE: | Major | feat!: new API |
chore:, docs:, style: | None | docs: update readme |
Cloudflare Workers & Containers
Cloudflare Containers allow you to run Docker containers on Cloudflare’s edge network, managed by Durable Objects. This gives you:
- Edge-deployed containers - Your Docker image runs close to users
- Auto-scaling - Containers spin up/down based on demand
- Sleep mode - Containers sleep after inactivity (cost savings)
- Managed registry - Push directly to Cloudflare’s container registry
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐│ Cloudflare Edge ││ ┌───────────────┐ ┌─────────────────────────────────┐ ││ │ Worker │──────│ Container Durable Object │ ││ │ (index.ts) │ │ ┌───────────────────────────┐ │ ││ │ │ │ │ Your Docker Container │ │ ││ │ • Routes │ │ │ (main-app:v1.0.0) │ │ ││ │ • Health │ │ │ │ │ ││ │ • Management │ │ │ PORT 8080 │ │ ││ └───────────────┘ │ └───────────────────────────┘ │ ││ └─────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────┘Flow:
- Request hits Cloudflare Worker
- Worker gets/creates Container Durable Object instance
- DO manages container lifecycle (start, stop, sleep)
- Worker proxies request to container
- Container responds via Worker
Directory Structure
infra/cloudflare/├── containers-worker/ # Main app container orchestration│ ├── wrangler.toml # Wrangler + container config│ ├── package.json│ ├── tsconfig.json│ └── src/│ └── index.ts # Worker + Container DO class│├── api-worker/ # Separate API worker (optional)├── websocket-relay/ # WebSocket DO (optional)└── README.mdDeployment Pipeline
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐│ 1. Build │───▶│ 2. Push │───▶│ 3. Sync │───▶│ 4. Deploy ││ (Docker) │ │ (Registry) │ │ (wrangler) │ │ (Worker) │└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ docker wrangler perl -pi wrangler buildx containers push (update toml) deploymake cf-deploy runs all 4 steps:
- Build Docker image locally with
docker buildx - Push to Cloudflare’s managed registry via
wrangler containers push - Update
wrangler.tomlwith the new image tag - Deploy the Worker which references the new image
Makefile Module
CF_ROOT := $(ROOT_DIR)/infra/cloudflareCF_WORKER_DIR := $(CF_ROOT)/containers-workerCF_WRANGLER_CONFIG := $(CF_WORKER_DIR)/wrangler.tomlCF_DOCKER_CONTEXT := $(ROOT_DIR)/infra/dockerfiles/main-appCF_DOCKERFILE := $(CF_DOCKER_CONTEXT)/DockerfileCF_DOCKER_PLATFORM := linux/amd64CF_IMAGE_NAME := main-app
# Version from package.json (source of truth)APP_VERSION := $(shell node $(ROOT_DIR)/scripts/app-version.mjs)CF_IMAGE_TAG := v$(APP_VERSION)
# Cloudflare registry URL pattern:# registry.cloudflare.com/<ACCOUNT_ID>/<IMAGE_NAME>:<TAG>CF_REGISTRY := registry.cloudflare.com/YOUR_ACCOUNT_ID/$(CF_IMAGE_NAME)CF_IMAGE_URI := $(CF_REGISTRY):$(CF_IMAGE_TAG)
# ═══════════════════════════════════════════════════════════════════# Authentication# ═══════════════════════════════════════════════════════════════════
.PHONY: cf-logincf-login: ## Authenticate with Cloudflare (opens browser) cd $(ROOT_DIR) && npx wrangler login
# ═══════════════════════════════════════════════════════════════════# Container Build & Push# ═══════════════════════════════════════════════════════════════════
.PHONY: cf-container-buildcf-container-build: ## Build and push via Wrangler (simpler, but less control) cd $(ROOT_DIR) && npx wrangler containers build $(CF_DOCKER_CONTEXT) \ --tag $(CF_IMAGE_NAME):$(CF_IMAGE_TAG) --push
.PHONY: cf-container-build-localcf-container-build-local: ## Build container locally with Docker (more control) docker buildx build \ --platform $(CF_DOCKER_PLATFORM) \ -f $(CF_DOCKERFILE) \ -t $(CF_IMAGE_NAME):$(CF_IMAGE_TAG) \ --build-arg APP_VERSION=$(APP_VERSION) \ --load \ $(CF_DOCKER_CONTEXT)
.PHONY: cf-container-pushcf-container-push: ## Push local image to Cloudflare registry cd $(ROOT_DIR) && npx wrangler containers push $(CF_IMAGE_NAME):$(CF_IMAGE_TAG)
.PHONY: cf-container-publishcf-container-publish: cf-container-build-local cf-container-push ## Build locally + push
.PHONY: cf-container-imagescf-container-images: ## List images in Cloudflare registry cd $(ROOT_DIR) && npx wrangler containers images list --filter $(CF_IMAGE_NAME)
# ═══════════════════════════════════════════════════════════════════# Wrangler Config Sync# ═══════════════════════════════════════════════════════════════════
.PHONY: cf-sync-wrangler-imagecf-sync-wrangler-image: ## Update wrangler.toml with latest image tag @echo "Updating image references to $(CF_IMAGE_URI)" @perl -0pi -e 's!image = "$(CF_REGISTRY):[^"]+"!image = "$(CF_IMAGE_URI)"!g' $(CF_WRANGLER_CONFIG)
# ═══════════════════════════════════════════════════════════════════# Worker Deployment# ═══════════════════════════════════════════════════════════════════
.PHONY: cf-worker-installcf-worker-install: ## Install worker dependencies cd $(CF_WORKER_DIR) && npm install
.PHONY: cf-worker-devcf-worker-dev: cf-worker-install ## Run worker locally (dev mode) cd $(CF_WORKER_DIR) && npm run dev
.PHONY: cf-worker-deploycf-worker-deploy: cf-worker-install ## Deploy worker only (no container build) cd $(ROOT_DIR) && npx wrangler deploy --config $(CF_WRANGLER_CONFIG) --env production
# ═══════════════════════════════════════════════════════════════════# Full Deploy (The Main Command)# ═══════════════════════════════════════════════════════════════════
.PHONY: cf-deploycf-deploy: cf-container-publish cf-sync-wrangler-image cf-worker-install ## Full deploy: build → push → sync → deploy git checkout main && git pull cd $(ROOT_DIR) && npx wrangler deploy --config $(CF_WRANGLER_CONFIG) --env production @echo "✅ Deployed $(CF_IMAGE_URI) to production"Worker Code Pattern
The Worker extends Container from @cloudflare/containers and handles routing:
import { Container, getContainer } from '@cloudflare/containers';
// Health check paths that return container state instead of proxyingconst HEALTH_PATHS = new Set(['/_health', '/_healthz', '/_readyz']);
// Default instance name (can be overridden via env or query param)const DEFAULT_INSTANCE = 'main-app-ssr';const CONTAINER_PORT = 8080;
/** * Container Durable Object class * Extends Container to configure your specific container settings */export class MainAppContainerDO extends Container { // Port your container listens on defaultPort = CONTAINER_PORT;
// Auto-sleep after 10 minutes of inactivity sleepAfter = '10m';
// Environment variables passed to the container envVars = { NODE_ENV: 'production', HOST: '0.0.0.0', PORT: `${CONTAINER_PORT}` };}
interface Env { // Durable Object binding (configured in wrangler.toml) APP_CONTAINER: DurableObjectNamespace<MainAppContainerDO>;
// Optional: Pin requests to a specific instance CONTAINER_INSTANCE_ID?: string;}
const worker = { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url);
// Determine which container instance to use // Priority: query param > env var > default const instanceId = url.searchParams.get('instance') ?? env.CONTAINER_INSTANCE_ID ?? DEFAULT_INSTANCE;
// Get container handle (creates if doesn't exist) const container = getContainer(env.APP_CONTAINER, instanceId);
// ───────────────────────────────────────────────────────────── // Health Check Endpoints - Return container state // ───────────────────────────────────────────────────────────── if (HEALTH_PATHS.has(url.pathname)) { const state = await container.getState(); return Response.json({ status: state.status, lastChange: new Date(state.lastChange).toISOString(), exitCode: 'exitCode' in state ? state.exitCode : undefined }, { headers: { 'cache-control': 'no-store' } }); }
// ───────────────────────────────────────────────────────────── // Management Endpoints - Container lifecycle control // ─────────────────────────────────────────────────────────────
// Force restart if (url.pathname === '/_container/restart' && request.method === 'POST') { await container.destroy(); return new Response('Restart requested', { status: 202 }); }
// Force start and wait for ready if (url.pathname === '/_container/start' && request.method === 'POST') { await container.startAndWaitForPorts({ ports: [CONTAINER_PORT] }); const state = await container.getState(); return Response.json({ message: 'Container started', state, instanceId }); }
// Get current status if (url.pathname === '/_container/status') { const state = await container.getState(); return Response.json({ state, instanceId }); }
// Graceful stop if (url.pathname === '/_container/stop' && request.method === 'POST') { await container.stop(); return new Response('Stop signal sent', { status: 202 }); }
// ───────────────────────────────────────────────────────────── // Default: Proxy to container // ───────────────────────────────────────────────────────────── return container.fetch(request); }};
export default worker;Worker package.json
{ "name": "containers-worker", "private": true, "type": "module", "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", "deploy:production": "wrangler deploy --env production" }, "dependencies": { "@cloudflare/containers": "^0.0.30" }, "devDependencies": { "@cloudflare/workers-types": "^4.20241112.0", "typescript": "^5.6.3", "wrangler": "^3.88.0" }}Wrangler Configuration (Annotated)
# Worker name (appears in dashboard)name = "main-app-worker"
# Entry pointmain = "src/index.ts"
# Cloudflare API compatibility datecompatibility_date = "2024-11-16"
# Your Cloudflare account ID (find in dashboard URL or `wrangler whoami`)account_id = "YOUR_ACCOUNT_ID"
# Enable workers.dev URL for testingworkers_dev = true
# ═══════════════════════════════════════════════════════════════════# Environment Variables (non-secret)# ═══════════════════════════════════════════════════════════════════[vars]CONTAINER_INSTANCE_ID = "main-app-dev"
# ═══════════════════════════════════════════════════════════════════# Container Configuration# ═══════════════════════════════════════════════════════════════════[[containers]]# Name for this container definitionname = "main-app"
# Must match the exported class name in your workerclass_name = "MainAppContainerDO"
# Full registry path: registry.cloudflare.com/<account_id>/<name>:<tag># This gets updated by `make cf-sync-wrangler-image`image = "registry.cloudflare.com/YOUR_ACCOUNT_ID/main-app:v1.0.0"
# Max concurrent container instancesmax_instances = 5
# ═══════════════════════════════════════════════════════════════════# Durable Object Bindings# ═══════════════════════════════════════════════════════════════════[[durable_objects.bindings]]# Name used in worker code: env.APP_CONTAINERname = "APP_CONTAINER"
# Must match class_name in [[containers]]class_name = "MainAppContainerDO"
# ═══════════════════════════════════════════════════════════════════# Migrations (required for Durable Objects)# ═══════════════════════════════════════════════════════════════════[[migrations]]tag = "v1"new_sqlite_classes = ["MainAppContainerDO"]
# ═══════════════════════════════════════════════════════════════════# Production Environment# ═══════════════════════════════════════════════════════════════════[env.production]# Disable workers.dev URL in productionworkers_dev = false
# Route configuration - attach to your domainroutes = [ { pattern = "yourdomain.com/*", zone_name = "yourdomain.com" }]
# Production container config (same structure, can have different settings)[[env.production.containers]]name = "main-app"class_name = "MainAppContainerDO"image = "registry.cloudflare.com/YOUR_ACCOUNT_ID/main-app:v1.0.0"max_instances = 5
[[env.production.durable_objects.bindings]]name = "APP_CONTAINER"class_name = "MainAppContainerDO"
[env.production.vars]CONTAINER_INSTANCE_ID = "main-app-prod"Management Endpoints
Once deployed, these endpoints are available:
| Endpoint | Method | Description |
|---|---|---|
/_health | GET | Container health status |
/_container/status | GET | Detailed container state |
/_container/start | POST | Force start container |
/_container/stop | POST | Graceful stop |
/_container/restart | POST | Destroy and recreate |
Usage:
# Check healthcurl https://yourdomain.com/_health
# Get detailed statuscurl https://yourdomain.com/_container/status
# Force start (useful for pre-warming)curl -X POST https://yourdomain.com/_container/start
# Restart after deploycurl -X POST https://yourdomain.com/_container/restartBootstrap: New Project Setup
1. Create the directory structure:
mkdir -p infra/cloudflare/containers-worker/srcmkdir -p infra/dockerfiles/main-app2. Initialize the worker package:
cd infra/cloudflare/containers-workernpm init -ynpm install @cloudflare/containersnpm install -D @cloudflare/workers-types typescript wrangler3. Create tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "lib": ["ES2022"], "types": ["@cloudflare/workers-types"], "strict": true, "skipLibCheck": true }, "include": ["src"]}4. Copy the worker code pattern (from above) to src/index.ts
5. Copy the wrangler.toml pattern and replace:
YOUR_ACCOUNT_IDwith your Cloudflare account IDmain-appwith your app nameyourdomain.comwith your domain
6. Login and deploy:
npx wrangler loginnpx wrangler deploy # deploys to workers.dev for testing7. First container push:
# From project rootdocker buildx build --platform linux/amd64 \ -f infra/dockerfiles/main-app/Dockerfile \ -t main-app:v1.0.0 \ --load \ infra/dockerfiles/main-app
npx wrangler containers push main-app:v1.0.08. Deploy to production:
npx wrangler deploy --env productionTroubleshooting
| Issue | Solution |
|---|---|
| Container not starting | Check /_container/status for exitCode |
| Image not found | Run wrangler containers images list to verify push |
| Wrong version deployed | Run make cf-sync-wrangler-image then redeploy |
| Container sleeping | Hit any endpoint or POST to /_container/start |
| Auth errors | Run wrangler login again |
Terraform Infrastructure
File Organization
infra/terraform/gpu-node/├── main.tf # Resources (droplet, firewall)├── variables.tf # Input variables with validation├── outputs.tf # Computed outputs├── versions.tf # Provider requirements├── cloud-init.yaml.tpl # VM bootstrap template├── env.example # Example environment file├── terraform.tfvars.example # Example variables file└── modules/ # Optional sub-modules ├── monitoring/ └── networking/Makefile Module
TF_DIR := $(ROOT_DIR)/infra/terraform/gpu-nodeSIDECAR_DIR := $(ROOT_DIR)/infra/dockerfiles/sidecar-service
# Load environment if exists-include $(TF_DIR)/.env
# ─────────────────────────────────────────────────────────────────# Terraform Commands# ─────────────────────────────────────────────────────────────────
.PHONY: gpu-initgpu-init: ## Initialize Terraform @cd $(TF_DIR) && terraform init
.PHONY: gpu-validategpu-validate: ## Validate configuration @cd $(TF_DIR) && terraform validate
.PHONY: gpu-plangpu-plan: ## Plan changes @cd $(TF_DIR) && terraform plan -var-file=terraform.tfvars
.PHONY: gpu-applygpu-apply: ## Apply changes (creates resources) @echo "⚠️ This will create billable resources!" @cd $(TF_DIR) && terraform apply -auto-approve -var-file=terraform.tfvars
.PHONY: gpu-destroygpu-destroy: ## Destroy all resources @cd $(TF_DIR) && terraform destroy -auto-approve -var-file=terraform.tfvars
.PHONY: gpu-outputgpu-output: ## Show outputs @cd $(TF_DIR) && terraform output
# ─────────────────────────────────────────────────────────────────# SSH Access# ─────────────────────────────────────────────────────────────────
.PHONY: gpu-sshgpu-ssh: ## SSH into the instance @IP=$$(cd $(TF_DIR) && terraform output -raw ipv4_address 2>/dev/null) && \ if [ -n "$$IP" ]; then ssh root@$$IP; \ else echo "❌ No instance running"; fi
.PHONY: gpu-logsgpu-logs: ## View container logs @IP=$$(cd $(TF_DIR) && terraform output -raw ipv4_address 2>/dev/null) && \ if [ -n "$$IP" ]; then \ ssh root@$$IP "docker compose logs -f --tail=100"; \ else echo "❌ No instance running"; fi
# ─────────────────────────────────────────────────────────────────# Sidecar Docker# ─────────────────────────────────────────────────────────────────
.PHONY: sidecar-buildsidecar-build: ## Build sidecar image @cd $(SIDECAR_DIR) && docker buildx build --platform linux/amd64 -t sidecar:latest .
.PHONY: sidecar-pushsidecar-push: ## Push to GHCR @cd $(SIDECAR_DIR) && docker buildx build --platform linux/amd64 \ -t ghcr.io/YOUR_ORG/sidecar:latest --push .
# ─────────────────────────────────────────────────────────────────# Setup# ─────────────────────────────────────────────────────────────────
.PHONY: gpu-setupgpu-setup: ## Interactive setup wizard @$(ROOT_DIR)/scripts/setup-gpu-infra.sh
.PHONY: gpu-testgpu-test: ## Validate infrastructure @$(ROOT_DIR)/scripts/validate-gpu-infra.shExample Files Pattern
# Copy to .env and fill in values. NEVER commit .env!DIGITALOCEAN_TOKEN=dop_v1_your_token_hereGHCR_TOKEN=ghp_your_github_token_here# Copy to terraform.tfvars. NEVER commit terraform.tfvars!
session_id = "sess-example001"user_id = "user-example"auth_token = "generate-with-openssl-rand-hex-32"
region = "nyc2"gpu_size = "gpu-h100x1-80gb"
admin_ssh_key_ids = []
ghcr_username = "your-github-username"ghcr_token = "ghp_your_token"Complete Bootstrap Checklist
1. Create Directory Structure
mkdir -p make infra/{dockerfiles,cloudflare,compose,terraform} scripts apps/main-app brain docs artifacts2. Create Root Makefile
Copy the root Makefile pattern from Makefile Module System.
3. Create Initial Modules
At minimum:
make/dev.mk- Development servermake/release.mk- Versioning
4. Set Up Docker
- Create
infra/dockerfiles/main-app/Dockerfile - For sidecars: create self-contained folders with their own
package.json
5. Set Up Release Tooling
cd apps/main-appnpm init -y # or yarn initnpm install -D standard-versionAdd to package.json:
{ "scripts": { "release": "standard-version", "release:minor": "standard-version --release-as minor", "release:major": "standard-version --release-as major", "release:patch": "standard-version --release-as patch", "release:dry": "standard-version --dry-run" }}6. Create Version Scripts
scripts/app-version.mjsscripts/release-sync-versions.mjs
7. Add to .gitignore
# Terraform*.tfstate*.tfstate.backup*.tfvars!*.tfvars.example.terraform/
# Environment**/.env!**/.env.example
# Artifactsartifacts/dist/node_modules/8. Verify Setup
make help # Should list all targetsmake release-dry # Should preview releaseQuick Reference
| Command | Description |
|---|---|
make help | List all targets |
make dev | Start dev server |
make release | Create release (auto-bump) |
make release-patch | Force patch bump |
make release-minor | Force minor bump |
make release-major | Force major bump |
make release-dry | Preview release |
make cf-deploy | Full Cloudflare deploy |
make cf-container-build-local | Build Docker image |
make gpu-apply | Create GPU infrastructure |
make gpu-destroy | Tear down infrastructure |
make gpu-ssh | SSH into instance |
make make-key | Generate secret |
make make-key FORMAT=base64 | Generate base64 secret |