Twelve-Factor Examples, Factors V through VIII
These examples continue the shared order service. One build produces the web, worker, and admin process entry points. Choose a language in any tab group. Every group on this page follows that selection.
Factor V: Build, release, run
The build creates one immutable artifact. A release adds deployment configuration and an identity. Runtime selects a process command without downloading dependencies or compiling source.
A multi-stage container restores from the npm lock, compiles once, and copies only runtime files forward. The deployment can identify those image bytes by digest while keeping release configuration outside them.
FROM node:24-alpine AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY tsconfig.json tsconfig.build.json ./COPY src ./srcRUN npm run build && npm prune --omit=dev
FROM node:24-alpine AS runtimeENV NODE_ENV=productionWORKDIR /appCOPY --from=build /app/package.json /app/package-lock.json ./COPY --from=build /app/node_modules ./node_modulesCOPY --from=build /app/dist ./distCOPY db ./dbUSER nodeCMD ["node", "dist/web.js"]uv restores the frozen dependency graph and project into one virtual environment. The runtime image copies that environment instead of resolving packages during startup.
FROM python:3.14-slim AS buildWORKDIR /appRUN pip install --no-cache-dir uv==0.9.22COPY pyproject.toml uv.lock README.md ./COPY src ./srcRUN uv sync --frozen --no-dev --no-editable
FROM python:3.14-slim AS runtimeENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1WORKDIR /appCOPY --from=build /app/.venv ./.venvCOPY db ./dbUSER 65532:65532CMD ["web"]The build compiles one static binary from the locked module graph. The runtime image copies those bytes and contains no Go toolchain or module cache.
FROM golang:1.25-alpine AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY cmd ./cmdCOPY internal ./internalRUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/orders ./cmd/orders
FROM alpine:3.22RUN addgroup -S app && adduser -S -G app appCOPY --from=build /out/orders /usr/local/bin/ordersUSER appENTRYPOINT ["orders"]CMD ["web"]Evidence to collect: Promote the same artifact digest between environments, change only release configuration, and roll back without rebuilding.
Factor VI: Processes
Web and worker instances keep no required durable state in memory or on their local filesystem. PostgreSQL stores orders and idempotency records. Redis coordinates queued work but does not replace the system of record.
The Fastify route receives a PostgreSQL pool. The database generates and persists the order identifier, so a later request can reach any web process.
async create(input: OrderInput, idempotencyKey: string, id = randomUUID()): Promise<CreateResult> { const client = await this.pool.connect(); try { await client.query("BEGIN"); const prior = await existingByKey(client, idempotencyKey); if (prior) { await client.query("COMMIT"); return prior.customerId === input.customerId && prior.amountCents === input.amountCents ? { kind: "duplicate", order: prior } : { kind: "conflict" }; } const inserted = await client.query<DbOrder>( `INSERT INTO orders (id, idempotency_key, customer_id, amount_cents, status, created_at) VALUES ($1, $2, $3, $4, 'accepted', now()) RETURNING *`, [id, idempotencyKey, input.customerId, input.amountCents] ); await client.query( "INSERT INTO order_jobs (order_id, schema_version) VALUES ($1, 1)", [id] ); await client.query("COMMIT"); const order = inserted.rows[0]; if (!order) throw new Error("insert did not return an order"); return { kind: "created", order: mapOrder(order) }; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); }FastAPI retrieves the pool from application state and writes through Psycopg. Application state holds a connection resource, not the durable order.
def create(self, value: OrderInput, idempotency_key: str) -> tuple[CreateKind, Order]: with self.pool.connection() as connection: with connection.transaction(): existing = connection.execute( """SELECT id, idempotency_key, customer_id, amount_cents, status, created_at, completed_at FROM orders WHERE idempotency_key = %s FOR UPDATE""", (idempotency_key,), ).fetchone() if existing: order = self._order(existing) same = order.customer_id == value.customer_id and order.amount_cents == value.amount_cents return ("duplicate" if same else "conflict", order) order_id = uuid4() now = datetime.now(UTC) row = connection.execute( """INSERT INTO orders(id, idempotency_key, customer_id, amount_cents, status, created_at) VALUES (%s, %s, %s, %s, 'accepted', %s) RETURNING id, idempotency_key, customer_id, amount_cents, status, created_at, completed_at""", (order_id, idempotency_key, value.customer_id, value.amount_cents, now), ).fetchone() job = new_job(order_id, idempotency_key) connection.execute( "INSERT INTO order_jobs(order_id, schema_version) VALUES (%s, %s)", (order_id, job.schema_version), ) if row is None: raise RuntimeError("order insert returned no row") return "created", self._order(row)A chi handler owns no durable map. Its pgxpool dependency writes the order and returns the stored representation.
func (p Postgres) Create(ctx context.Context, key string, input domain.OrderInput) (domain.Order, bool, error) { tx, err := p.Pool.Begin(ctx) if err != nil { return domain.Order{}, false, err } defer tx.Rollback(ctx) now := time.Now().UTC() var order domain.Order err = tx.QueryRow(ctx, ` INSERT INTO orders(id,idempotency_key,customer_id,amount_cents,status,created_at) VALUES(gen_random_uuid(),$1,$2,$3,'accepted',$4) ON CONFLICT(idempotency_key) DO NOTHING RETURNING id::text,idempotency_key,customer_id,amount_cents,status,created_at,completed_at`, key, input.CustomerID, input.AmountCents, now, ).Scan(&order.ID, &order.IdempotencyKey, &order.CustomerID, &order.AmountCents, &order.Status, &order.CreatedAt, &order.CompletedAt) created := err == nil if errors.Is(err, pgx.ErrNoRows) { order, err = getWith(ctx, tx, key, true) if err == nil && (order.CustomerID != input.CustomerID || order.AmountCents != input.AmountCents) { return domain.Order{}, false, domain.ErrConflict } } if err != nil { return domain.Order{}, false, err } if created { if _, err = tx.Exec(ctx, `INSERT INTO order_jobs(order_id,schema_version) VALUES($1,1)`, order.ID); err != nil { return domain.Order{}, false, err } } if err = tx.Commit(ctx); err != nil { return domain.Order{}, false, err } return order, created, nil}Evidence to collect: Terminate the instance that accepted an order, route the next read to another instance, and verify the same durable result.
Factor VII: Port binding
The web process owns its HTTP listener and binds the configured port. The platform routes to that listener. Workers and admin jobs expose commands because their invocation model is not HTTP.
Fastify owns the HTTP server and listens on the validated port. Rejected startup propagates to the process entry point, so a bind failure cannot look ready.
export async function runWeb(): Promise<void> { const config = parseConfig(process.env, "web"); const tracing = startTracing(config.TELEMETRY_MODE); const pool = createPool(config.DATABASE_URL); const store = new PostgresOrderStore(pool); const queue = new BullOrderQueue(config.REDIS_URL!); const readiness = new Readiness(); const app = buildApp({ store, queue, readiness, releaseId: config.RELEASE_ID }); try { await Promise.all([assertDatabaseReady(pool), queue.ready()]); await app.listen({ host: config.APP_HOST, port: config.PORT }); } catch (error) { await Promise.allSettled([app.close(), queue.close(), store.close(), tracing?.shutdown()]); throw error; } readiness.markReady(); const interval = setInterval(() => void dispatchPending(store, queue), 1000); emit(eventRecord("web", config.RELEASE_ID, "info", "web.ready"));Uvicorn serves the FastAPI application directly. The entry point receives its host and port from validated settings rather than treating the public URL as a listener address.
def main() -> None: app, settings = create_runtime_app() uvicorn.run( app, host=settings.app_host, port=settings.port, log_config=None, timeout_graceful_shutdown=settings.shutdown_grace_ms // 1000, )http.Server owns the listener and serves a chi router. ListenAndServe returns bind and runtime failures to the command entry point.
state := &readiness.State{} api := httpapi.API{Orders: store.Postgres{Pool: pool}, Queue: queueClient, Readiness: state} server := &http.Server{ Addr: net.JoinHostPort(cfg.AppHost, fmt.Sprintf("%d", cfg.Port)), Handler: otelhttp.NewHandler(api.Router(), "orders.http"), ReadHeaderTimeout: 5 * time.Second, } errorsCh := make(chan error, 1) go func() { errorsCh <- server.ListenAndServe() }() state.MarkReady() telemetry.Event(ctx, logger, "web", cfg.ReleaseID, "web.ready", "port", cfg.Port)Evidence to collect: Start the artifact with a new valid port and route traffic without installing a framework-specific server on the host.
Factor VIII: Concurrency
Web and worker fleets scale as separate process types. Each worker also limits in-process concurrency so horizontal scaling does not multiply unbounded work.
BullMQ caps active jobs in each worker process. The platform can add worker replicas independently, while the per-process bound protects database and API capacity.
export function createOrderWorker( redisUrl: string, store: OrderStore, concurrency: number, onResult: (job: Job<OrderJob>, result: string) => void): Worker<OrderJob> { return new Worker<OrderJob>( queueName, async (job) => { const payload = orderJobSchema.parse(job.data); const result = await store.complete(payload.orderId); if (result === "missing") throw new Error("order does not exist"); onResult(job, result); return result; }, { connection: redisConnection(redisUrl), concurrency } );}The Dramatiq actor defines retry and execution limits. The worker command fixes the process and thread counts, which gives each replica a known concurrency budget.
try: pool.open(wait=True) assert_database_ready(pool) actor = create_order_actor( broker, store, lambda job, result: emit(event_record( "worker", settings.release_id, "info", f"order.{result}", orderId=str(job.order_id) )), ) del actor broker.client.ping() worker = Worker(broker, worker_threads=settings.worker_concurrency) worker.start()
def stop(_signum: int, _frame: object) -> None: stopped.set()
signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) readiness.mark_ready() emit(event_record("worker", settings.release_id, "info", "worker.ready"))Asynq gives the worker process a fixed concurrency and weighted queues. Adding replicas increases capacity in explicit increments instead of opening an unbounded goroutine path.
redisOptions, err := orderqueue.RedisOptions(cfg.RedisURL) if err != nil { return fmt.Errorf("queue unavailable") } server := asynq.NewServer(redisOptions, asynq.Config{Concurrency: cfg.WorkerConcurrency, Queues: map[string]int{orderqueue.QueueName: 1}}) mux := asynq.NewServeMux() processor := orderqueue.Processor{ Orders: store.Postgres{Pool: pool}, OnResult: func(ctx context.Context, job domain.Job, result string) { retryCount, _ := asynq.GetRetryCount(ctx) telemetry.Event(ctx, logger, "worker", cfg.ReleaseID, "order."+result, "orderId", job.OrderID, "attempt", retryCount+1) }, } mux.HandleFunc(orderqueue.TaskType, processor.Handle) if err := server.Start(mux); err != nil { return err } telemetry.Event(ctx, logger, "worker", cfg.ReleaseID, "worker.ready", "concurrency", cfg.WorkerConcurrency)Evidence to collect: Increase worker replicas while holding the web fleet constant, then verify queue throughput improves without exceeding database connection limits.
Reference releases
The examples on this page are exact excerpts from independently runnable releases:
- TypeScript
v1.0.2, verified withnpm run verify - Python
v1.0.2, verified withuv run python scripts/check_twelve_factor.py - Go
v1.0.3, verified with./scripts/check-twelve-factor.sh