You have a Docker container that runs on your laptop. You have beta customers who were promised access "this week." You have a bank balance that shrinks every month. And there is a voice in your head saying you should first "do infrastructure properly": a VPC, a Terraform module, maybe a small Kubernetes cluster so you are ready to scale.
That voice has cost a lot of founders their runway. The good news is that "doing it properly" for an early SaaS has been written down for more than a decade, and it has nothing to do with Terraform. It is called the Twelve-Factor App methodology, and 12 factor apps are exactly the kind of software that managed platforms can run without you touching a server.
This guide walks through the twelve factors from the point of view of someone who needs a live HTTPS URL in the next 48 hours, not a certification. For each factor you get what it means, what to change in your code, and what you can safely ignore for now.
What 12 factor apps actually are
The Twelve-Factor App was published by Heroku engineers around 2011 as a summary of patterns they saw in thousands of apps running on their platform. The idea is simple: if your app follows a small set of rules about config, state, processes, and logs, then the platform underneath can build it, run it, restart it, and scale it without knowing anything about your code. In late 2024 the manifesto was open-sourced so the community could modernize it, but the core twelve rules have not changed.
The important insight for a solo founder is this:
The twelve factors are a contract between your app and the platform. If you keep your side, you do not need to build the other side yourself.
When people burn eleven hours on networking configs, they are usually building the platform side of that contract by hand. You do not have to.
The twelve factors, rewritten for a 48-hour deadline
1. Codebase: one repo, many deploys
One codebase tracked in Git, deployed to multiple environments (staging, production). Not one repo per environment, not a prod folder with copied files.
What to do today: make sure your app lives in a single Git repository and that main is always deployable. That single fact unlocks auto-deploys on push, which is the fastest feedback loop you will ever have.
2. Dependencies: declare and isolate them
Every dependency is declared in a manifest (package.json, pyproject.toml, go.mod, composer.json) and locked. Nothing relies on a tool "that happens to be installed on the server."
Your Dockerfile already does most of this. Check for the classic leaks:
# Good: lockfile copied first, installed deterministically
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]If your app shells out to ffmpeg, imagemagick, or pg_dump, install them in the Dockerfile. If it works on your Mac only because Homebrew installed something two years ago, it will break in production at the worst moment.
3. Config: store it in the environment
This is the factor that saves the most pain. Anything that differs between environments (database URLs, API keys, S3 bucket names, feature flags) comes from environment variables, not from files committed to the repo.
# settings.py
import os
DATABASE_URL = os.environ["DATABASE_URL"] # fail fast if missing
S3_BUCKET = os.environ["S3_BUCKET"]
S3_ENDPOINT = os.environ.get("S3_ENDPOINT") # lets you point at any S3-compatible store
STRIPE_SECRET_KEY = os.environ["STRIPE_SECRET_KEY"]
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"A quick test: could you open-source your repo right now without leaking a single credential? If not, move those values into env vars before you deploy. Keep a .env.example in the repo listing every variable name with a fake value, and keep the real .env out of Git.
4. Backing services: treat them as attached resources
Your database, object storage, cache, and email provider are "attached resources" reachable by a URL or credentials in config. Swapping a local Postgres for a managed one should be a config change, not a code change.
In practice that means no hard-coded localhost:5432, and your storage client should accept a custom endpoint:
import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({
region: process.env.S3_REGION ?? "auto",
endpoint: process.env.S3_ENDPOINT, // any S3-compatible provider
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
});This is also your insurance policy against lock-in. If your storage and database are just URLs, moving providers later is an afternoon, not a rewrite.
5. Build, release, run: keep the stages separate
- Build turns code into an artifact (your Docker image).
- Release combines that artifact with config for a specific environment.
- Run starts processes from a release.
The rule: never edit code on a running server. No SSH-and-patch hotfixes. If something is broken, you push a fix, a new build happens, and a new release goes out. Rollback means redeploying the previous release.
On a managed platform you get this for free when builds are triggered by Git pushes. Your job is only to avoid build steps that depend on runtime secrets. For example, do not bake DATABASE_URL into the image at build time; read it when the process starts.
6. Processes: stateless and share-nothing
Your app runs as one or more stateless processes. Anything that must survive a restart goes into a backing service.
The usual offenders in MVPs:
- User uploads written to local disk. They vanish on redeploy and are invisible to a second instance. Put them in S3-compatible storage.
- In-memory sessions. Log in on instance A, get logged out on instance B. Use signed cookies or a database/Redis session store.
- Local SQLite in production. Fine on a laptop, a liability when the container is replaced.
If you fix only one thing before launch, fix this. Stateless processes are what make "automatic scaling" a checkbox instead of a project.
7. Port binding: export services via a port
Your app is self-contained and serves HTTP by binding to a port, usually read from PORT:
const port = Number(process.env.PORT) || 3000;
app.listen(port, "0.0.0.0", () => console.log(`listening on ${port}`));Note the 0.0.0.0. Binding to 127.0.0.1 inside a container is the single most common reason a deploy "succeeds" but the app is unreachable. TLS termination and routing belong to the platform, not your code.
8. Concurrency: scale out with the process model
Scale by running more processes, not by making one process bigger. Split work by type: web processes handle requests, worker processes handle background jobs.
For an MVP this usually means two things:
- Move slow work (sending emails, generating PDFs, calling LLM APIs) out of the request path into a queue and worker.
- Make sure nothing assumes it is the only instance, for example cron-like logic that would run twice if you had two web containers.
9. Disposability: fast startup, graceful shutdown
Processes should start in seconds and shut down cleanly on SIGTERM. Platforms stop and replace containers during deploys and scaling events, so this is not optional.
process.on("SIGTERM", () => {
server.close(() => {
db.end().finally(() => process.exit(0));
});
});Also make sure your Dockerfile's CMD uses the exec form (CMD ["node", "server.js"]), so your app is PID 1 and actually receives the signal. The shell form (CMD node server.js) wraps it in /bin/sh, which often swallows SIGTERM.
10. Dev/prod parity: keep environments similar
Use the same kind of backing services in development and production. If production runs Postgres, do not develop against SQLite. Docker Compose makes this cheap:
# compose.yaml - local development only
services:
app:
build: .
ports:
- "3000:3000"
env_file: .env
depends_on:
db:
condition: service_healthy
db:
image: postgres:18
environment:
POSTGRES_PASSWORD: dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10Run it with docker compose up. The point is not to replicate production exactly. The point is to catch "works on SQLite, breaks on Postgres" before your beta users do.
11. Logs: treat them as event streams
Write logs to stdout and stderr. Do not write log files, do not rotate them, do not ship them yourself. The platform collects the stream.
Structured JSON logs are worth the ten minutes it takes to set them up:
import json, logging, sys
class JsonFormatter(logging.Formatter):
def format(self, record):
return json.dumps({"level": record.levelname, "msg": record.getMessage(), "logger": record.name})
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])12. Admin processes: run one-off tasks in the same environment
Migrations, data backfills, and console sessions run as one-off processes using the same image and config as the app. They live in the repo, not in your shell history.
The simplest pattern for an MVP is running migrations as part of startup or as a release step, and making them idempotent:
CMD ["sh", "-c", "npx prisma migrate deploy && exec node server.js"]The exec keeps your app as PID 1 so factor 9 still holds. Once you run multiple instances, move migrations to a dedicated release step so two containers do not race.
What you can ignore until you have paying customers
The contrarian part: most "production readiness" checklists are written for companies with a platform team. With 14 weeks of runway, these can wait:
- Infrastructure as code. Terraform is great when you have ten environments and three engineers. With one app and one person, a dashboard you can understand beats a module you have to debug.
- Kubernetes. You do not need a scheduler when a platform already schedules your containers.
- Multi-region. Your first 20 customers will not notice 80 ms of latency. They will notice that the product does not exist yet.
- Custom observability stacks. Stdout logs plus one error tracker is enough to start.
What you cannot skip is the list above: env-based config, stateless processes, port binding, graceful shutdown. Those are cheap now and expensive to retrofit.
Deploying a 12-factor app on Granite.so
Once your app follows the factors, the platform side is mostly clicking. Here is how the pieces map on Granite.so:
| Factor | What Granite.so handles |
|---|---|
| Codebase, build/release/run | Connect your Git repo; every push to your branch triggers a build and deploy. You can also deploy a prebuilt Docker image. |
| Config | Set environment variables per app in the dashboard. |
| Backing services | Create a managed database and S3-compatible storage (with CDN) and attach their credentials as env vars. |
| Port binding | Your container listens on a port; HTTPS and routing are handled for you. |
| Concurrency, disposability | Automatic scaling adds and replaces instances, which works because your processes are stateless. |
| Logs | Write to stdout and read the stream from the platform instead of managing log files on a server. |
A realistic first-deploy sequence:
- Push your repo with a working Dockerfile (or point to an image in a registry).
- Create a managed Postgres database and copy its connection string into a
DATABASE_URLenv var on your app. - Create an S3-compatible storage bucket and add
S3_ENDPOINT,S3_BUCKET, and the access keys as env vars. Serve public assets through the built-in CDN. - Add the rest of your config from
.env.example: Stripe keys, email provider, session secret. - Deploy, watch the logs, and open the HTTPS URL.
- Invite your beta users.
If your app already follows factors 3, 4, 6, and 7, that sequence is realistically an hour, not a weekend. The Hobby plan is $9 per month and includes $15 of monthly usage credits, which comfortably covers a small MVP with a database and storage while you validate demand.
Honest trade-off: a managed platform gives you less low-level control than a raw VM. If your product needs custom kernel modules, GPUs, or unusual networking, you will outgrow this setup. Most B2B SaaS MVPs are a web process, a worker, a database, and a bucket, and never come close to those limits.
A pre-launch checklist for 12 factor apps
Run through this before you send the invite email:
- No secrets in the repo;
.env.examplelists every variable - App reads
PORTand binds to0.0.0.0 - Uploads go to object storage, not local disk
- Sessions survive a container restart
CMDuses exec form and the app handlesSIGTERM- Migrations run automatically and are idempotent
- Logs go to stdout
- Local development uses the same database engine as production
- A push to
maindeploys without manual steps
Ship first, optimize later
The Twelve-Factor methodology has survived fifteen years because it draws a clean line: you own the app, the platform owns the machines. Every hour you spend on the platform's side of that line is an hour not spent talking to customers.
If you want that line drawn for you, Granite.so runs your container, database, and storage from one dashboard, starting at $9 per month. Connect the repo, set your env vars, and spend the rest of the week on the thing your beta users are actually waiting for: the product.