How ONTLE Runs: A FastAPI, MySQL, and Cloudflare Tunnel Architecture

A field-tested stack that lets a small team run many subdomains safely, walked through choice by choice — the reasoning behind each, the trade-offs it carries, and a couple of common misconceptions set straight.
← 목록으로
이
이서연
2026년 10월 03일 Updated 2026.10.16 37 min read views 2

ONTLE runs on a single FastAPI (async Python) process that serves both server-rendered pages and JSON APIs, stores data in MySQL with the charset pinned to utf8mb4, reaches the outside world through a Cloudflare tunnel with no inbound ports, opens new subdomains via wildcard DNS without a code deploy, keeps processes alive with systemd, and offloads static and media to a CDN. Each of these six choices is an answer to one question: can a solo operator or a small team realistically carry dozens of subdomain services? This post walks through why this particular combination, what we gained, and what we accepted in return — in order. Along the way it rewrites two inaccurate claims from an earlier draft: the security effect of the tunnel, and MySQL's default charset.

Why start with the architecture

When people hear "an AI PM builds the service for you," they tend to ask about the model first. But the reason a service stays up for months, holds steady as subdomains grow into the dozens, and keeps responding when traffic spikes at 3 a.m. is not the model — it's the infrastructure underneath. ONTLE is a system where an AI PM takes requirements and publishes a new subdomain on top of this infrastructure. The reason publishing happens "instantly," and the way a published service stays isolated, both come entirely from the six layers covered here.

Here's an analogy. When you put up a building, instead of fitting a door to every room and a lock to every window, you can choose a design that concentrates security at a single front entrance and eliminates the rest. ONTLE's infrastructure mostly follows this approach. But as we'll see, narrowing the entrance to one place does not make "what can happen inside" disappear. Let's open each layer up one at a time.

1. Application layer — one FastAPI doing both rendering and API

FastAPI is a Python web framework that runs on top of ASGI (the Asynchronous Server Gateway Interface). "Async" means that while one request waits on a database or an external API, it doesn't sit idle holding its slot — it hands the slot over so other requests can be processed. A traditional synchronous setup has to add threads or processes in proportion to the number of requests to raise concurrency, whereas an async setup has a single event loop take turns servicing many waiting requests.

Why this matters for ONTLE: instead of spinning up a separate application server per subdomain, a single FastAPI process (or a small number of workers) has to field requests for many services together. If a new process were launched every time the AI PM published a service, both server resources and operational overhead would grow in lockstep with the number of services. The async model keeps that proportionality gentle.

FastAPI handles server-side-rendering routes, which draw HTML on the server and send it down, and the JSON API routes a client calls — both in the same codebase and the same process. The reason not to split the rendering server from the API server is simple: each subdomain has different needs, and if you split the servers, every new service means re-aligning deploys, authentication, and routing between the two.

Weak version

"Each service needs its own server to be safe and clean."

It sounds like it raises isolation, but in practice every new service adds a deploy pipeline, a health check, a port allocation, and a reverse-proxy config. With three or four, you cope; at ten or twenty, operational overhead grows faster than the service count.

Strong version

"Register per-subdomain routers in a single FastAPI application, and tell services apart by the host value in the request header."

Adding a new subdomain comes down to "register a router + add a DB record," and process-management overhead doesn't balloon with the number of services.

2. Data layer — MySQL and utf8mb4

Why MySQL

MySQL is a relational database, well suited to data with clear referential relationships like services, users, subdomains, and content. In ONTLE, "whose subdomain this is, which template it uses, and when it was published" is typically expressed with foreign keys across tables. A document database is also an option, but when many subdomains share a common schema (publish status, owner, routing path) and differ only in service-specific details, the relational model makes consistency easier to hold.

"Declare" utf8mb4 explicitly — setting a common misconception straight

First, let's correct a widespread but wrong claim. The statement "MySQL's default charset is a 3-bytes-per-character utf8" is no longer true. As of MySQL 8.0 the server default charset is utf8mb4, and 8.4 is the same. What used to be called utf8 is actually an alias for utf8mb3, which supports at most 3 bytes per character and is now deprecated. In other words, the one that can't hold 4-byte characters is the old utf8 (= utf8mb3), not utf8mb4.

So on 8.0 and up, can you do nothing at all? No. ONTLE pins the charset to a fixed value for three reasons.

First, migrations and legacy. A schema created on an older server, or carried over from the 5.7 era, may still be utf8mb3 or latin1. The server being 8.x does not automatically convert existing tables.

Second, the server default and the connection/table settings are separate things. Even if the server is utf8mb4, the client connection charset or an individual table or column may differ. MySQL performs conversion between charsets when they don't match, but if the target charset can't represent a character (for example, a 4-byte emoji you're trying to store in a utf8mb3 column), or the actual byte encoding disagrees with the declared charset, that's where loss or errors occur.

Third, being explicit is safer. To avoid depending on version and environment, it's better to pin the table, column, connection, and collation all to utf8mb4. So ONTLE creates new tables like this.

-- Don't rely on the server default; declare charset and collation explicitly
CREATE TABLE ws_domains (
  id          BIGINT       NOT NULL AUTO_INCREMENT,
  subdomain   VARCHAR(63)  NOT NULL,
  owner_id    BIGINT       NOT NULL,
  route_path  VARCHAR(255) NOT NULL,
  created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uq_subdomain (subdomain)
) ENGINE=InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_0900_ai_ci;   -- the default collation on 8.0 and up

We attach the charset to the connection too. Even when the server is utf8mb4, if the connection charset is a value that can't represent it or disagrees with the actual data's encoding, loss or errors can creep in during the round-trip conversion.

# SQLAlchemy(async) + aiomysql — pin the connection charset to utf8mb4 as well
engine = create_async_engine(
    "mysql+aiomysql://user:[email protected]/ontle_dev?charset=utf8mb4",
    pool_pre_ping=True,
)

With this, Korean of course — but also emoji and extended CJK characters, which are 4 bytes — are stored safely at the server, the connection, and the table alike. Changing the charset in production requires converting whole tables, which is costly, so this value belongs in the design stage.

Choice What you gain What you accept in return
MySQL (relational) Referential integrity, a familiar operational ecosystem, transactions Migrations needed for schema changes; horizontal scaling is more cumbersome than document stores
Declaring utf8mb4 4-byte characters guaranteed regardless of server version, connection, or migration state A slight impact on storage and index-key-length math compared to utf8mb3

3. External-entry layer — a Cloudflare tunnel, with no inbound ports

The problem with port forwarding and the DMZ

To expose a server to the outside, the traditional approach is port forwarding — opening a port on the router or firewall and handing connections through to the internal server — or placing the server in an isolated network segment (a DMZ). What these have in common is that the server directly receives connections coming from the internet. That port gets scanned, and if it's vulnerable, it gets attacked directly. The moment you open a port, you're announcing to the entire internet, "there's a server here."

A tunnel inverts the direction

A Cloudflare tunnel works the other way around. The internal server first makes an outbound connection to Cloudflare and keeps it alive. When an external user visits the domain, the request reaches the Cloudflare edge first, and Cloudflare forwards it to the internal server through that already-open tunnel. From the server's point of view, there's no inbound port left open, and neither a router port-forwarding rule nor a DMZ setup is needed.

[Old way: port forwarding]
Internet ---(public inbound port)---> Router ---> Internal server
              ^ a target for port scans and direct attacks

[Tunnel way]
Internal server ---(outbound connection, the server initiates)---> Cloudflare edge
Internet user ---(visits domain)---> Cloudflare edge ---(existing tunnel)---> Internal server
              ^ no open inbound port, yet a request to the domain still reaches the app

What shrinks, and what stays the same (correction)

This is the part the earlier draft got wrong. The old post said "in a tunnel setup there is no point for an attacker to even attempt a connection," and that is not true. Let's draw the distinction precisely.

What shrinks is the network-layer attack surface. With no open inbound port, attacks like port scans against the server's IP, direct strikes, or probing for accidentally exposed management ports (SSH, DB) become hard to carry out. The path to "plugging straight into" the server from the internet disappears.

What stays is the application-layer attack surface. A legitimate request arriving at the public domain abc.ontle.kr rides the tunnel all the way to the application. The domain is the point of contact, and that request reaches the app. So web vulnerabilities — SQL injection, XSS, authentication bypass, privilege escalation, business-logic abuse — remain valid whether the tunnel is there or not. The tunnel is not a mechanism that blocks these requests.

Weak version

"We use a tunnel, so there's nothing to attack. No need to worry about web security."

Strong version

"A tunnel reduces network-layer exposure. Defending against web vulnerabilities (input validation, authentication, authorization checks, WAF) has to be done separately in the application."

It's true — and a real benefit — that the Cloudflare edge handles TLS termination, DDoS mitigation, and, depending on configuration, defenses like a WAF, reinforcing some layers on your behalf. But that does not replace the application's own input validation, authentication, and authorization checks. Back to the front-door analogy: it's closer to narrowing the entrances to one and stationing a professional guard in front of it. There are fewer holes in the walls, but what a guest you let in through the front door does once inside is still something you have to manage from inside.

Trade-offs

It isn't free. Since all external traffic passes through the Cloudflare edge, your availability is tied to Cloudflare's outages and policy changes. Because the server is assumed to sit on an internal network, that network's power and line stability translate directly into service availability. The extra hop through the edge also adds latency. Even so, for a small team the burden of "depending on a single tunnel" is generally lighter than "opening ports and running intrusion detection yourself."

4. Routing layer — instant subdomain provisioning via wildcard DNS

A wildcard DNS record (*.ontle.kr) means every subdomain request not defined in advance is received at one place. Whether it's abc.ontle.kr or xyz.ontle.kr, at the DNS stage they head to the same destination. The key is that DNS doesn't need to know in advance. Instead of adding a record for each new subdomain, the wildcard is already catching every case, so "which subdomain actually exists" is decided by the application and the database.

The FastAPI server looks at the host header of the incoming request, queries the DB, and decides routing. Opening a new service becomes a matter of adding one DB record, not a DNS change or a separate deploy.

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

@app.middleware("http")
async def route_by_host(request: Request, call_next):
    host = request.headers.get("host", "").split(":")[0]
    # The wildcard lets everything in, but existence is decided here by the DB
    ws = await lookup_workspace(host)   # None if unregistered
    if ws is None:
        return JSONResponse({"detail": "subdomain not provisioned"}, status_code=404)
    request.state.workspace = ws
    return await call_next(request)

This is the fundamental reason a service opens within seconds, with no separate infrastructure work, when the AI PM publishes a subdomain.

There's a trade-off too. The wildcard lets requests for non-existent subdomains reach the server anyway. So you need filtering logic that rejects unregistered requests quickly and accurately. Also, TLS is handled by a wildcard certificate, and depth beyond the certificate's scope (for example, a.b.ontle.kr) needs separate consideration.

5. Process-management layer — systemd user services

If you just run a Python process in a terminal, it dies when the session drops or the server reboots. systemd is Linux's standard process-management tool, and registering a user service lets you set a policy to bring the process back up when it dies unexpectedly. Automatic startup after a reboot gets configured too.

Dedicated orchestration tools (container clusters and the like) are powerful, but they come with learning and operating costs. Unless you're running hundreds of services at thousands of requests per second, systemd — bundled with the distro — is enough to satisfy the core requirement: "if it dies, it comes back." Using a tool you already have precisely beats layering another one on, and it keeps complexity down.

That said, endless restarts can mask the root cause (a code bug, resource exhaustion). If you set Restart=always and call it done, you can leave a service that keeps dying and reviving unnoticed. That's why the restart policy lives alongside logging and alerts.

6. Static and media layer — the CDN

Static files like images, video, CSS, and JavaScript don't change their contents from request to request. If the application server reads and serves such files directly every time, the resources spent on static delivery eat into the resources that should go to dynamic logic (DB queries, rendering). A CDN caches static assets in many locations and serves them straight from somewhere close to the user. The application server focuses on dynamic work, and the CDN absorbs bandwidth-heavy static traffic. The smaller the team, the more this separation changes how much headroom a single server has.

One principle runs through all six layers: the smaller the team, the more each choice should cut the number of things you have to operate yourself. But knowing exactly how far that cutting holds matters just as much. The tunnel reduces network exposure, yet web-vulnerability defense remains; the charset default got better, yet being explicit is still necessary.

Frequently asked questions

If I just use a Cloudflare tunnel, can I stop worrying about web security?

No. The tunnel only reduces network-layer exposure by removing the server's open inbound ports. A request arriving at the public domain reaches the application through the tunnel as-is, so web vulnerabilities like SQL injection, XSS, and authentication bypass remain valid. Input validation, authentication, and authorization checks must still be done separately in the application.

I'm on MySQL 8.0 — do I really need to declare utf8mb4?

It's recommended. The server default charset on 8.0 and 8.4 is already utf8mb4, but tables that were migrated or carried over from older versions may be utf8mb3 (the old utf8) or latin1. MySQL performs conversion when charsets differ, but if the target charset can't represent a character, or the actual encoding disagrees with the declared value, loss or errors occur at that point. Declaring the table, column, connection, and collation all as utf8mb4 keeps 4-byte characters safe regardless of version or environment.

If you don't run a separate server per subdomain, how do you isolate services from each other?

ONTLE tells workspaces apart by the host header within a single application, and routes by carrying the workspace in the request context. For data, it silos externally integrated collected data and credentials into a project-dedicated DB. It's an approach that establishes data and permission boundaries at the application layer without splitting processes.

Is systemd's Restart=always alone enough for operations?

Not enough. Restarting guarantees "if it dies, it comes back," but it can mask the root cause (bugs, resource exhaustion) behind repeated restarts. You have to watch restart counts and failure logs and pair them with alerts, so you don't leave a service quietly dying and reviving.

References