Twelve-Factor Examples, Factors I through IV
These examples use the same order-service contract in three ecosystems. Each application exposes web, worker, and admin process types from one versioned source tree. Choose a language in any tab group. Every group on this page follows that selection.
Factor I: Codebase
The deployable application is the unit of identity. Its web, worker, and admin processes come from one revision even though each starts through a different command.
One package.json exposes the web, worker, and admin commands. Every process starts from the same compiled package and locked dependency graph.
"scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", "start:web": "node dist/web.js", "start:worker": "node dist/worker.js", "admin": "node dist/admin.js", "dev:web": "tsx src/web.ts", "dev:worker": "tsx src/worker.ts", "test:unit": "vitest run tests/unit", "test:integration": "vitest run tests/integration", "test": "vitest run", "check:secrets": "node scripts/check-secrets.mjs", "check:twelve-factor": "node scripts/check-twelve-factor.mjs", "verify": "npm run check:twelve-factor", "local:up": "docker compose -p tf_typescript up -d postgres redis", "local:down": "docker compose -p tf_typescript down", "local:clean": "node scripts/cleanup.mjs"One package exposes wrappers for the FastAPI web process, Dramatiq worker, and Typer admin application.
[project.scripts]web = "order_service.web:main"worker = "order_service.worker:main"admin = "order_service.admin:main"One Go module builds a Cobra command with web, worker, and admin subcommands that share internal application packages.
func New(logger *slog.Logger) *cobra.Command { root := &cobra.Command{ Use: "orders", Short: "Twelve-Factor order service", SilenceUsage: true, SilenceErrors: true, } root.AddCommand(webCommand(logger), workerCommand(logger), adminCommand(logger)) return root}Evidence to collect: Given a running process, recover its release identifier, source revision, and named build target.
Factor II: Dependencies
The dependency declaration is complete only when a clean environment can resolve the same graph without relying on globally installed packages.
package.json declares direct dependencies. package-lock.json records the resolved graph. npm ci rejects drift between them, and npm ls proves the named runtime libraries resolve.
npm cinpm run verifypyproject.toml declares direct dependencies. uv.lock records exact resolutions. Frozen synchronization rejects an outdated lock, and the import command checks the installed runtime surface.
uv sync --frozenuv run python scripts/check_twelve_factor.pygo.mod declares module requirements. go.sum records integrity data. Module download and the repository test commands exercise the resolved dependency graph.
go mod downloadgo test ./internal/...go test ./tests/integration/..../scripts/check-twelve-factor.shEvidence to collect: Install and test from an empty dependency cache or clean container, then inspect the runtime artifact for undeclared assumptions.
Factor III: Config
The application owns a typed configuration schema. Each deployment supplies values. Startup rejects missing or invalid input before accepting traffic or work.
Zod parses deploy values once and returns a typed object. Calling code receives config, not unrestricted access to process.env.
const baseSchema = z.object({ APP_HOST: z.string().default("0.0.0.0"), PORT: z.coerce.number().int().min(1024).max(65535).default(3101), DATABASE_URL: z.url().refine((value) => usesScheme(value, ["postgres:", "postgresql:"]), { message: "must use PostgreSQL" }), REDIS_URL: z.url().refine((value) => usesScheme(value, ["redis:", "rediss:"]), { message: "must use Redis" }).optional(), RELEASE_ID: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/), WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(32).default(4), SHUTDOWN_GRACE_MS: z.coerce.number().int().min(1000).max(60000).default(10000), TELEMETRY_MODE: z.enum(["console", "memory", "disabled"]).default("console")});Pydantic Settings validates the same boundary before FastAPI or Dramatiq starts. Validation errors name fields without logging their values.
class Settings(BaseSettings): model_config = SettingsConfigDict(extra="ignore")
database_url: str = Field(alias="DATABASE_URL") redis_url: str | None = Field(default=None, alias="REDIS_URL") release_id: str = Field(alias="RELEASE_ID", pattern=r"^[A-Za-z0-9._-]{1,64}$") app_host: str = Field(default="0.0.0.0", alias="APP_HOST") port: int = Field(default=3102, ge=1024, le=65535, alias="PORT") worker_concurrency: int = Field(default=4, ge=1, le=32, alias="WORKER_CONCURRENCY") shutdown_grace_ms: int = Field(default=10000, ge=1000, le=60000, alias="SHUTDOWN_GRACE_MS") telemetry_mode: Literal["console", "memory", "disabled"] = Field(default="console", alias="TELEMETRY_MODE")caarlos0/env decodes environment values into a typed struct. The loader validates fields before starting listeners or resource clients.
type Config struct { AppHost string `env:"APP_HOST" envDefault:"0.0.0.0"` DatabaseURL string `env:"DATABASE_URL,required"` RedisURL string `env:"REDIS_URL"` Port int `env:"PORT" envDefault:"3103"` WorkerConcurrency int `env:"WORKER_CONCURRENCY" envDefault:"4"` ShutdownGraceMS int `env:"SHUTDOWN_GRACE_MS" envDefault:"10000"` ReleaseID string `env:"RELEASE_ID,required"` TelemetryMode string `env:"TELEMETRY_MODE" envDefault:"console"`}Evidence to collect: Start with a required value missing, a port outside its valid range, and a malformed backing-service URL. Each run must fail safely before readiness.
Factor IV: Backing services
PostgreSQL and the queue are attachments, not host assumptions. The application receives their endpoints and credentials through validated configuration.
The BullMQ client is constructed at startup from a validated Redis endpoint. The queue accepts an injected ioredis connection, and application modules receive the explicit queue boundary.
export class BullOrderQueue implements OrderQueue { readonly queue: Queue<OrderJob>;
constructor(redisUrl: string) { this.queue = new Queue<OrderJob>(queueName, { connection: redisConnection(redisUrl) }); }
async ready(): Promise<void> { await this.queue.waitUntilReady(); }
async publish(job: OrderJob): Promise<void> { await this.queue.add("complete-order", job, { jobId: job.orderId, attempts: 3, backoff: { type: "exponential", delay: 250 }, removeOnComplete: 50, removeOnFail: 50 }); }Psycopg and Dramatiq resources are created from validated settings. The application startup boundary owns their lifecycle.
def create_runtime_app() -> tuple[FastAPI, Settings]: settings = load_settings(os.environ, "web") pool = create_pool(settings.database_url) store = PostgresOrderStore(pool) broker = create_broker(settings.redis_url or "") actor = create_order_actor(broker, store, lambda _job, _result: None) queue = DramatiqOrderQueue(broker, actor) readiness = Readiness()pgxpool and Asynq clients are built at the composition root. Handlers, workers, and commands receive them through constructors.
pool, err := store.NewPool(ctx, cfg.DatabaseURL) if err != nil { return fmt.Errorf("database unavailable") } defer pool.Close() if err := migrations.Ready(ctx, pool); err != nil { return err } queueClient, err := queue.NewClient(cfg.RedisURL) if err != nil { return fmt.Errorf("queue unavailable") } defer queueClient.Close() state := &readiness.State{} api := httpapi.API{Orders: store.Postgres{Pool: pool}, Queue: queueClient, Readiness: state}Evidence to collect: Replace an attachment endpoint without editing source, then run contract tests that expose semantic differences instead of assuming providers are interchangeable.
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