Building a Public API Without Exposing Your Private Application
I ran a private Flask application for my gaming community. It tracked individual player performance in the game's Union Raid mode: team compositions, hit rates, guild-level strategy notes we did not want shared outside the group. Operationally sensitive in the small-stakes way guild data always is.
At the same time, a meaningful portion of what the app stored was not sensitive. Boss attack patterns are documented on community wikis. Unit tier rankings are debated across game community forums. Team composition archetypes derived from historical raid data, stripped of individual attribution, are the kind of thing any player would find useful.
The question was how to share that data without creating any path from the public surface into the private one.
The Wrong Answer
The obvious approach is to add a public endpoint to the existing application. A /public/bosses route with a restricted result set. A middleware layer that strips identifying columns.
This is wrong for one reason: if the public endpoint has a vulnerability, the attacker is already inside the private application. SQL injection in a public route that queries the same database the private routes use means access to the full schema. A misconfigured response header could reveal internal routing. The attack surface of the private application becomes the attack surface of the public API, because they are the same process.
The principle I applied: the public API and the private application should share no code paths, no runtime, no dependencies, and no infrastructure. If the public API is completely compromised, there is nothing adjacent to pivot to. The isolation boundary is architectural.
A Separate Deployment on Cloudflare Workers
Starting in November 2025, I built a standalone Cloudflare Worker as the public API. Separate repository, separate D1 database, separate deployment, different runtime entirely. The private app is Python on a VPS. The public API is TypeScript on Cloudflare's edge. They share no language, no runtime, no host, no database connection.
The stack: TypeScript with Hono as the web framework, Chanfana for automatic OpenAPI 3.1 spec generation, Cloudflare D1 as the database, and Zod for schema validation throughout. The wrangler.jsonc binds only the public D1 database:
{
"main": "src/index.ts",
"name": "nikke-public-api",
"d1_databases": [
{
"binding": "DB",
"database_name": "openapi-nikke-db",
"database_id": "<your-d1-database-id>"
}
],
"vars": {
"ASSET_BASE_URL": "https://assets.example.com"
}
}
No credentials to the private application. No reference to the private app's hostname anywhere in the codebase.
Deciding What Is Safe to Make Public
Before writing code, I worked through the data boundary explicitly.
Boss names, elemental weaknesses, attack patterns, and debuff mechanics are community knowledge, documented on wikis. Unit tier rankings and investment recommendations are my guild's synthesis of published content. Team composition patterns from historical raid data are safe as aggregates: which burst combinations appear most in high-scoring runs, which element matchups recur. Individual player records, usernames, skill level breakdowns, and internal strategy notes never entered the public database. They were never migrated there.
The data transfer was not a live sync. I exported the public-safe subset, transformed it into a schema designed for the public use case, and loaded it into D1 via numbered migration files. The private Flask app remains the authoritative source for its data.
Chanfana: The OpenAPI Spec Is the Implementation
The detail I appreciated most: the OpenAPI spec requires no separate maintenance. Each route class defines its schema inline, and Chanfana generates the spec from those definitions at serve time.
export class BossList extends OpenAPIRoute {
schema = {
tags: ["Bosses"],
summary: "List all boss units with optional filters",
parameters: [
{
name: "weakness",
in: "query",
required: false,
description: "Filter by elemental weakness (Fire, Water, Electric, Wind, Iron)",
schema: { type: "string" },
},
],
responses: {
"200": {
description: "List of bosses",
content: {
"application/json": { schema: BossListResponseSchema },
},
},
},
};
async handle(c: AppContext) {
const { weakness } = c.req.query();
const db = c.env.DB;
const results = await db
.prepare(weakness
? "SELECT id, name, season, weakness FROM bosses WHERE LOWER(weakness) = LOWER(?)"
: "SELECT id, name, season, weakness FROM bosses"
)
.bind(...(weakness ? [weakness] : []))
.all<z.infer<typeof BossSummarySchema>>();
return c.json({ success: true, data: results.results || [] });
}
}
BossListResponseSchema is a Zod schema in a shared schema file. Chanfana reads it and generates the corresponding OpenAPI JSON Schema. The spec and the implementation are the same artifact. No drift, no separate spec file to maintain.
D1 for a Read-Mostly Store
Unit and boss data changes infrequently. Boss data for a season is entered once at season start. Units release on the game's schedule. The read-to-write ratio is well above 100:1.
D1's eventual consistency is acceptable precisely because of that ratio. A reader getting slightly stale tier data for a few seconds is not a meaningful problem for a gaming reference API.
The alternative, having the public API proxy requests to the private Flask app, would couple their availability, expose credentials, and add 50-150ms of network latency on every request. D1 lookups from a Worker execute in the same data center. The tradeoff is manual: when boss data changes, I update both systems. That discipline is a small price for the isolation.
What Got Built
By January 2026, the API covered 175+ units with tier rankings and investment data, 41 unique boss and season combinations across seasons 28 through 37 with structured attack patterns and debuffs, and team composition endpoints derived from anonymized aggregate raid data.
The architectural decision that made this possible was made before the first commit: the public API would have no connection to the private application. Not a restricted one. Not a read-only one. No connection. Everything else followed from that constraint.
Related Articles
API Economics and MCP: Designing Tools for Credit-Metered Threat Intelligence
When building MCP integrations for credit-metered APIs, the interesting design decisions are not about the protocol. They are about cache consistency, rate limiting, and how narrow tool scope changes what an AI assistant can do autonomously.
CSPM vs. Reality: What Cloud Security Tools Promise and What They Actually Deliver
A field perspective on cloud security posture management tools after running competitive bake-offs against Wiz, Lacework, and Sysdig across Fortune 500 environments. What the demos don't show you.
SSL Proxy: Splunk & NGINX
How to use NGINX and Let's Encrypt to put a secure SSL reverse proxy in front of a Splunk Search Head running on an unprivileged port.