APIForge - Dynamic Container Provisioning and Isolated PostgreSQL Databases
How APIForge spins up isolated Docker containers and dedicated PostgreSQL databases per user from a single plain-English prompt — the engine under the hood.
The Core Problem: Isolation at Scale
When you type a plain-English description into APIForge — say, "a todo app with users, tasks, and due dates" — something has to translate that into real infrastructure. Not shared tables in a monolithic database. Not a mocked response. An actual, running PostgreSQL database. An actual Docker container. An actual subdomain.
This post covers how that provisioning pipeline works.
Why Full Isolation?
The easy path would be a shared database with a tenant_id column on every table. It's cheaper, simpler, and faster to provision. We explicitly chose not to do this.
Shared database problems:
- A bad query from one tenant can lock tables and degrade everyone
- Schema migrations for one user affect all users
- Data leakage risk if a query is written incorrectly
- You can't give users direct database credentials without exposing others
With full isolation, each API gets:
- Its own PostgreSQL container with its own port
- Its own credentials that only work for that database
- Its own Docker network namespace
- Its own filesystem volume
The cost is slightly more resource usage. The benefit is that every user's data and compute is completely separated.
The Provisioning Pipeline
Here's what happens from the moment a user submits a description to the moment their API is live:
User submits prompt
↓
LLM parses schema + endpoint definitions
↓
Schema validation + conflict check
↓
PostgreSQL container spun up
↓
Database initialized (tables, constraints, indexes)
↓
API container built from generated code
↓
Nginx config updated (subdomain routing)
↓
Health check passes
↓
API live at {slug}.apiforgelive.xyz
Total time: under 60 seconds for most schemas.
Step 1: Schema Extraction from Plain English
The first step is turning the user's description into a structured schema. We send the prompt to an LLM with a strict JSON output schema:
interface GeneratedSchema {
apiName: string;
slug: string;
tables: {
name: string;
columns: {
name: string;
type: "text" | "integer" | "boolean" | "timestamp" | "uuid";
nullable: boolean;
defaultValue?: string;
}[];
primaryKey: string;
foreignKeys?: {
column: string;
references: { table: string; column: string };
}[];
}[];
endpoints: {
method: "GET" | "POST" | "PUT" | "DELETE";
path: string;
description: string;
table: string;
}[];
}The LLM output is validated against this schema using Zod before anything is provisioned. If the output is malformed or the slug conflicts with an existing one, the process stops here.
Step 2: Spinning Up PostgreSQL
Each API gets its own PostgreSQL 15 container. We use the official postgres:15-alpine image for minimal footprint.
async function provisionDatabase(schema: GeneratedSchema): Promise<DbConfig> {
const dbName = `apiforge_${schema.slug}`;
const dbUser = `user_${schema.slug}`;
const dbPassword = generateSecurePassword();
const hostPort = await findFreePort(5433, 6000);
await docker.createContainer({
Image: "postgres:15-alpine",
name: `pg_${schema.slug}`,
Env: [
`POSTGRES_DB=${dbName}`,
`POSTGRES_USER=${dbUser}`,
`POSTGRES_PASSWORD=${dbPassword}`,
],
HostConfig: {
PortBindings: {
"5432/tcp": [{ HostPort: String(hostPort) }],
},
NetworkMode: `net_${schema.slug}`,
Binds: [`vol_${schema.slug}:/var/lib/postgresql/data`],
Memory: 256 * 1024 * 1024, // 256MB cap
CpuQuota: 50000, // 50% of one CPU
},
});
await waitForPostgres(hostPort, dbUser, dbPassword, dbName);
return { hostPort, dbName, dbUser, dbPassword };
}The waitForPostgres function polls the TCP port and attempts a connection every 500ms, timing out after 30 seconds.
Step 3: Initializing the Schema
Once the database is up, we run the generated DDL. We build the SQL from the validated schema object — no raw SQL from the LLM ever touches the database.
function buildDDL(schema: GeneratedSchema): string {
return schema.tables
.map((table) => {
const columns = table.columns.map((col) => {
const parts = [
` ${col.name}`,
mapType(col.type),
col.name === table.primaryKey ? "PRIMARY KEY" : "",
col.nullable ? "" : "NOT NULL",
col.defaultValue ? `DEFAULT ${col.defaultValue}` : "",
].filter(Boolean);
return parts.join(" ");
});
const fkConstraints = (table.foreignKeys ?? []).map(
(fk) =>
` CONSTRAINT fk_${fk.column} FOREIGN KEY (${fk.column}) ` +
`REFERENCES ${fk.references.table}(${fk.references.column}) ON DELETE CASCADE`
);
return [
`CREATE TABLE IF NOT EXISTS ${table.name} (`,
[...columns, ...fkConstraints].join(",\n"),
");",
].join("\n");
})
.join("\n\n");
}The key constraint here: the LLM generates the schema spec, not SQL. The SQL is generated from the validated spec by our own code. This eliminates SQL injection from the generation step entirely.
Step 4: Building the API Container
The generated API is a Node.js + Express app. We have a base Docker image with Node.js and all common dependencies pre-installed. The provisioner writes the generated route files into a temp directory and builds a final image on top of the base:
FROM apiforge-base:latest
WORKDIR /app
# Generated files injected at build time
COPY generated/routes ./routes
COPY generated/db.js ./db.js
COPY generated/index.js ./index.js
EXPOSE 3000
CMD ["node", "index.js"]Each generated route file follows a predictable pattern:
// routes/tasks.js — generated for a "tasks" table
import { Router } from "express";
import { pool } from "../db.js";
const router = Router();
router.get("/tasks", async (req, res) => {
const { rows } = await pool.query("SELECT * FROM tasks LIMIT $1 OFFSET $2", [
req.query.limit ?? 50,
req.query.offset ?? 0,
]);
res.json(rows);
});
router.post("/tasks", async (req, res) => {
const { title, done, due_date, user_id } = req.body;
const { rows } = await pool.query(
"INSERT INTO tasks (title, done, due_date, user_id) VALUES ($1, $2, $3, $4) RETURNING *",
[title, done, due_date, user_id]
);
res.status(201).json(rows[0]);
});
// ... PUT, DELETE routes
export default router;Step 5: Health Check and DNS
Once both containers are running, Nginx is reloaded with a new upstream block pointing to the API container's port. We use nginx -s reload (not restart) to avoid dropping existing connections.
A health check hits GET /health on the new subdomain every second for up to 30 seconds. Once it returns 200, the API is marked as live in the database and the dashboard updates in real time over WebSocket.
Resource Limits and Cleanup
Every provisioned container has hard limits:
| Resource | Database | API |
|---|---|---|
| Memory | 256MB | 128MB |
| CPU | 50% of one core | 25% of one core |
| Disk (volume) | 1GB | — |
| Network | Isolated bridge | Isolated bridge |
When a user deletes their API, a cleanup job removes both containers, drops the Docker volumes, removes the network, and deletes the Nginx config block. Nothing lingers.
What's Next
The provisioning pipeline gives every user a real, isolated backend in under a minute. In the next post, we'll cover what happens after provisioning: subdomain routing, auto-generated OpenAPI documentation, and the API management layer — rate limiting, request monitoring, and usage analytics.
APIForge is live at apiforgelive.xyz. The source is at github.com/ishikabhoyar/apiforge.