Skip to content

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 build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY db ./db
USER node
CMD ["node", "dist/web.js"]

Factor V: Build, release, run TypeScript source

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();
}

Factor VI: Processes TypeScript source

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"));

Factor VII: Port binding TypeScript source

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 }
);
}

Factor VIII: Concurrency TypeScript source

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: