The Containerized Web App
This architecture keeps a process of yours running whether or not anyone is asking it for anything. A container holds your code, a load balancer puts it on the internet, and a database and a bucket hold what has to outlive a deploy. The always-on process is the point: it can keep connection pools and in-process caches, it runs in whatever runtime you choose, and no request pays to start it. This page explains how the four pieces are wired and what each connection actually provisions.
The Architecture
The diagram is the file below, rendered by the same canvas the product uses. Traffic enters at domain on the left and reaches app through edge. app is the only component talking to both the database and the bucket, and nothing else is reachable from outside.
| Component | What it does |
|---|---|
| app | A container built from your repository whenever the ref changes |
| db | A managed PostgreSQL instance |
| uploads | Object storage for files users hand you |
| edge | The load balancer every request arrives at |
| domain | A domain you have verified, and the hostname it serves |
{
"version": "2.0",
"platform": "aws",
"components": [
{ "type": "Network", "name": "network" },
{
"type": "Container",
"name": "app",
"source": {
"git": { "url": "github.com/you/app", "ref": "main" }
},
"ports": [{ "name": "http", "port": 8000 }]
},
{ "type": "Database", "name": "db", "db_name": "app" },
{ "type": "Bucket", "name": "uploads" },
{ "type": "LoadBalancer", "name": "edge" },
{ "type": "ImportedDomain", "name": "domain",
"zone_name": "example.com", "zone_id": "Z0EXAMPLE123456" }
],
"connections": [
{ "from": "domain", "to": "edge", "host": "app.example.com" },
{ "from": "network", "to": "app" },
{ "from": "network", "to": "db" },
{ "from": "network", "to": "edge" },
{ "from": "edge", "to": "app", "health_check_path": "/health" },
{ "from": "app", "to": "db" },
{ "from": "app", "to": "uploads" }
]
}The Wiring
Three of these connections place components on the network and one puts the application on a hostname. The other three carry capability, and each provisions more than it appears to.
app → db is the clearest example. It injects DB_HOST, DB_PORT, DB_NAME, DB_USER, and DB_PASSWORD into the container, and the password appears in none of them at authoring time: the database generates its own, keeps it in the managed secret store, and the connection resolves it when the infrastructure is applied, so it is never in your file, your image, or your repository. A Secret resolves later, when the container starts, which is a distinction Self-Hosted Open WebUI leans on. The same connection opens a network path from this container to this database and widens nothing else. app → uploads does the equivalent for storage, injecting UPLOADS_BUCKET and granting that one container read and write access to that one bucket. Both sets of names are built from the target component’s own name, so renaming db renames DB_HOST with it, and the value in UPLOADS_BUCKET is the bucket’s real name, which carries an account suffix for global uniqueness and is not the uploads you wrote. Read the variable rather than assuming either.
edge → app is what makes the application reachable. Because it carries no host or path, it is the load balancer’s default route, so every request that arrives goes to the container’s http port. A request travels from DNS to the load balancer and onward over the private network to a container that holds no certificate and needs to know nothing about the hostname. TLS terminates at the load balancer, on the certificate that arrives with the hostname.
domain → edge is the front door. It writes an A-alias record for app.example.com into the hosted zone the ImportedDomain names, and attaches that zone’s certificate to the load balancer’s HTTPS listener, which is why nothing downstream handles TLS or knows its own hostname. The host on this connection is the only place the public name appears. Point a second hostname at the same load balancer by adding another ImportedDomain connection rather than by changing this one.
Building is driven by the container’s source. A git source means the platform clones the repository, builds the image, and stores it for this project whenever the ref or the build arguments change; a Dockerfile is the only requirement. The ports entry has to match the port your process actually binds, because that number is what the load balancer sends traffic to. When one repository holds several services, context and dockerfile narrow the build to a subdirectory or a differently named file.
The Decisions Behind It
A container rather than a function. The premise here is a process that stays up, and that is a choice rather than a starting point. A Function answers requests too: an ImportedDomain → Function connection puts one on a hostname through an API Gateway HTTP API and needs no load balancer at all, so it is a smaller architecture rather than a less capable one. What separates them is whether the process should be always on. A container suits work that holds something between requests, keeps a warm connection pool, runs longer than a function’s timeout, or needs a runtime that function packaging does not fit. A function suits work that arrives in bursts and can start from nothing, and it costs nothing between invocations. A function also has to be attached to the network before it can reach a Database or a Volume, which a container already is.
A managed database rather than a container. You could run Postgres as a container next to the app and save a component. The reason not to is that Database is the one stateful thing here that gets a real disk, automated backups, and a password you never see, while a container’s filesystem is erased on every deploy. The moment your data matters more than your convenience, this stops being a close call.
Files in a bucket rather than on a disk. Uploads have two plausible homes: a Volume mounted into the container, or a Bucket. A volume is right when software insists on writing to a path it owns, which is the case for the tools in Self-Hosted n8n. For files your own code handles, the bucket is better: it is reachable from any number of containers at once, it survives everything, and its contents can be handed to browsers through presigned URLs so large files never travel through your application.
One container rather than several. This architecture runs the frontend, the API, and any background work in a single process. Every split costs a separate build, a separate deploy, and another place a change has to land, so the honest trigger for paying that is deploy cadence or scaling that genuinely diverges, not tidiness.
When it arrives, the split is three mechanisms rather than a new architecture. A second connection out of edge carrying a path or a host routes only matching requests to the new service, so one load balancer fronts as many services as you give rules to and they keep sharing an origin. A container that declares no ports cannot be routed to at all, which is the whole definition of a background worker. And a connection from one container to another injects the target’s host and port and opens that single path, so services find each other by name on the private network rather than through the internet.
A health route rather than the default. The load balancer only sends traffic to targets that answer health_check_path, and it defaults to /. That works until your homepage requires a session or waits on a database that is still warming, at which point deploys begin to fail in a way that looks like the platform’s fault. A route that returns 200 as soon as the process is listening keeps that signal honest.
This file names no security groups, IAM policies, or subnet placements, yet the running system has all three. They are derived from the connections, and deleting a connection removes the ones it created. You can read the generated infrastructure whenever you want to; most of the time there is no reason to.
The Limits
This architecture carries an application a long way, and it runs out in predictable places. The container is one deployable unit, so a slow background job and a web request compete for the same CPU; when that starts to hurt, the work moves to a scheduled function or a worker container. The database is a single instance sized by instance_class and allocated_storage, and storage does not grow on its own, so it wants sizing for the data rather than the traffic. Container sizing has its own constraint: cpu and memory pair only in combinations the schema accepts, and it rejects the rest at parse time.
The front door assumes a domain you have already verified. zone_name and zone_id name a hosted zone that has to exist before this file is applied, so both values here stand in for your own, and Custom Domains covers getting one to that state. Without one, delete domain and its connection: the project then answers on its generated spawned.app address, which already has HTTPS, and the rest of the file is unchanged.