APIForge - Subdomain Routing, OpenAPI Generation, and the API Management Layer
How APIForge handles per-tenant subdomain routing via Nginx, auto-generates OpenAPI specs from provisioned schemas, and enforces rate limiting and usage analytics per API key.
From Container to Public URL
Once a user's API container is running, it's only accessible on a random internal port on the host. The next problem is making it reachable at a clean public URL — {slug}.apiforgelive.xyz — without restarting anything, without manual DNS changes, and without dropping any existing connections.
This post covers three things that happen after provisioning: subdomain routing via dynamic Nginx config, OpenAPI spec generation, and the API management layer (rate limiting, request logging, and usage analytics).
Dynamic Nginx Configuration
We use Nginx as the reverse proxy. The approach is simple: each provisioned API gets its own config file written to /etc/nginx/conf.d/, then Nginx is reloaded.
function writeNginxConfig(slug: string, containerPort: number): void {
const config = `
server {
listen 80;
server_name ${slug}.apiforgelive.xyz;
location / {
proxy_pass http://127.0.0.1:${containerPort};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Api-Slug ${slug};
proxy_read_timeout 30s;
proxy_connect_timeout 5s;
}
}
`;
fs.writeFileSync(`/etc/nginx/conf.d/apiforge_${slug}.conf`, config.trim());
execSync("nginx -s reload");
}The nginx -s reload sends a SIGHUP to the master process. Nginx re-reads all config files and spawns new workers with the updated config while the old workers finish serving any in-flight requests. Zero downtime.
Wildcard DNS
The wildcard *.apiforgelive.xyz DNS record points to the server IP. Any subdomain resolves to the same host — Nginx then handles routing based on the server_name directive. No per-tenant DNS record needed.
SSL
A wildcard certificate from Let's Encrypt covers all subdomains. Certbot auto-renews it. The Nginx config above is the HTTP version; in production it redirects to HTTPS via a separate 443 block with the cert paths.
Auto-Generated OpenAPI Documentation
Every provisioned API gets a /docs endpoint that serves an interactive Swagger UI, and a /openapi.json endpoint that returns the raw spec. Both are generated from the same schema object we validated during provisioning — no separate step needed.
function generateOpenAPISpec(schema: GeneratedSchema): OpenAPIObject {
const paths: Record<string, PathItemObject> = {};
for (const endpoint of schema.endpoints) {
const path = endpoint.path;
const method = endpoint.method.toLowerCase() as HttpMethod;
if (!paths[path]) paths[path] = {};
const table = schema.tables.find((t) => t.name === endpoint.table)!;
paths[path][method] = {
summary: endpoint.description,
tags: [endpoint.table],
parameters: method === "get" ? buildQueryParams() : [],
requestBody: ["post", "put"].includes(method)
? buildRequestBody(table)
: undefined,
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: buildResponseSchema(table, method === "get"),
},
},
},
"400": { description: "Bad Request" },
"404": { description: "Not Found" },
"500": { description: "Internal Server Error" },
},
};
}
return {
openapi: "3.0.3",
info: {
title: schema.apiName,
description: `Auto-generated API for ${schema.apiName}`,
version: "1.0.0",
},
servers: [{ url: `https://${schema.slug}.apiforgelive.xyz` }],
paths,
components: {
securitySchemes: {
ApiKeyAuth: {
type: "apiKey",
in: "header",
name: "X-API-Key",
},
},
},
};
}The spec is stored in the database at provision time and served statically. The Swagger UI is bundled into the API container's static assets — no external CDN calls.
The API Management Layer
Once the API is live, we need to enforce rate limits, log requests, and track usage. This all happens inside a middleware layer injected into every generated API.
Rate Limiting with Redis
Rate limits are enforced using a sliding window counter in Redis. Each API key gets its own namespace:
async function checkRateLimit(
apiKey: string,
tier: "free" | "pro" | "enterprise"
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const limits = {
free: { requests: 100, windowMs: 60_000 }, // 100 req/min
pro: { requests: 1000, windowMs: 60_000 }, // 1000 req/min
enterprise: { requests: 10_000, windowMs: 60_000 },
};
const { requests, windowMs } = limits[tier];
const windowKey = `rl:${apiKey}:${Math.floor(Date.now() / windowMs)}`;
const current = await redis.incr(windowKey);
if (current === 1) {
await redis.pexpire(windowKey, windowMs);
}
const resetAt = (Math.floor(Date.now() / windowMs) + 1) * windowMs;
return {
allowed: current <= requests,
remaining: Math.max(0, requests - current),
resetAt,
};
}The response includes standard rate limit headers:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1738425600000
When a request is blocked, it returns 429 Too Many Requests with a Retry-After header.
Request Logging
Every request is logged asynchronously to avoid adding latency to the hot path. The log entry is written to a queue (a simple in-memory buffer, flushed every second to PostgreSQL):
interface RequestLog {
apiSlug: string;
apiKeyId: string;
method: string;
path: string;
statusCode: number;
durationMs: number;
requestBytes: number;
responseBytes: number;
timestamp: Date;
ipHash: string; // SHA-256 of IP, for privacy
}The log is the source of truth for the analytics dashboard. We aggregate it with materialized views refreshed every minute:
CREATE MATERIALIZED VIEW api_usage_hourly AS
SELECT
api_slug,
DATE_TRUNC('hour', timestamp) AS hour,
COUNT(*) AS total_requests,
AVG(duration_ms) AS avg_latency_ms,
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99_latency_ms,
SUM(CASE WHEN status_code >= 500 THEN 1 ELSE 0 END) AS error_count,
SUM(response_bytes) AS total_response_bytes
FROM request_logs
WHERE timestamp > NOW() - INTERVAL '7 days'
GROUP BY api_slug, DATE_TRUNC('hour', timestamp);The Dashboard
The analytics dashboard polls these materialized views and displays:
- Request volume — total requests over time, broken down by endpoint
- Latency — p50/p95/p99 response times
- Error rate — 4xx vs 5xx breakdown
- Top endpoints — most-called routes
- Bandwidth — inbound and outbound bytes
All of this is scoped to the user's API key — a user can only see their own metrics.
API Key Authentication
Every request to a provisioned API must include an X-API-Key header. Keys are prefixed with af_ for easy identification and stored as bcrypt hashes:
async function validateApiKey(
rawKey: string
): Promise<{ valid: boolean; keyId: string; tier: string } | null> {
// Keys are prefixed: af_live_xxxxxxxxxxxxx
const prefix = rawKey.slice(0, 8); // "af_live_"
const candidates = await db.query(
"SELECT id, key_hash, tier FROM api_keys WHERE prefix = $1 AND revoked = false",
[prefix]
);
for (const candidate of candidates.rows) {
if (await bcrypt.compare(rawKey, candidate.key_hash)) {
return { valid: true, keyId: candidate.id, tier: candidate.tier };
}
}
return null;
}The prefix lookup narrows the bcrypt comparison to a small set of candidates, keeping auth fast even with many keys in the database.
What's Next
With routing, documentation, and API management in place, the last piece is billing — connecting Stripe, enforcing tier limits, and the full end-to-end production setup. That's the next post.
APIForge is live at apiforgelive.xyz. Source at github.com/ishikabhoyar/apiforge.