Self-Hosted n8n
Running software you did not write is a different architectural problem from running your own. You cannot change how it stores data or which port it binds, so the infrastructure has to be built around facts the image dictates. This page uses n8n to work through that, and the same three questions settle the design for almost any self-hosted tool.
The Architecture
| Component | What it does |
|---|---|
| n8n | A container running the published image |
| data | A persistent disk for workflows and credentials |
| edge | The HTTPS load balancer |
| domain | A domain you have verified, and the hostname it serves |
{
"version": "2.0",
"platform": "aws",
"components": [
{ "type": "Network", "name": "network" },
{
"type": "Container",
"name": "n8n",
"source": { "image": "docker.n8n.io/n8nio/n8n:stable" },
"ports": [{ "name": "http", "port": 5678 }],
"cpu": 0.5,
"memory": "1Gi"
},
{ "type": "Volume", "name": "data" },
{ "type": "LoadBalancer", "name": "edge" },
{ "type": "ImportedDomain", "name": "domain",
"zone_name": "example.com", "zone_id": "Z0EXAMPLE123456" }
],
"connections": [
{ "from": "domain", "to": "edge", "host": "n8n.example.com" },
{ "from": "network", "to": "n8n" },
{ "from": "network", "to": "data" },
{ "from": "network", "to": "edge" },
{ "from": "edge", "to": "n8n", "health_check_path": "/healthz" },
{ "from": "n8n", "to": "data", "mount_path": "/home/node/.n8n" }
]
}The Wiring
The container differs from one you build in exactly one field. An image source is pulled at task start and run as it is, so nothing is cloned and nothing is built; the version you get is whatever the reference resolves to. A git source would instead have CI build and push an image on every change. That single field is the whole difference, which is why moving a component between the two later is trivial.
The volume is what makes the tool usable across restarts, and it is networked storage rather than a disk belonging to one machine. n8n → data mounts it into the container at mount_path and opens the path between the two. Because the storage lives outside the running task, replacing that task leaves the data untouched, and more than one workload can mount the same volume at once.
Three of the values in this file are dictated by the image and could not be guessed. Port 5678 is what n8n binds. /home/node/.n8n is where it keeps its SQLite database, its credentials, and its encryption key, which is why the volume is mounted at precisely that path and not somewhere tidier. /healthz is the endpoint that reports readiness, so it is what the load balancer polls before sending traffic. Every self-hosted image has its own three answers, and finding them in the project’s own documentation is most of the work.
Everything else the tool exposes is configured through the container’s env map, which is absent above because none of its values are ours to guess. n8n in particular builds the callback URLs it hands to external services from the hostname you tell it about, so a real install sets that from n8n’s own environment reference; leave it at the default and self-hosted webhooks point at localhost.
domain → edge puts the tool on n8n.example.com, writing the record into the hosted zone the ImportedDomain names and attaching its certificate to the load balancer. The image never learns the hostname, which matters here because n8n reads its own public URL from configuration rather than from the request. The zone has to be verified first; without one, drop domain and its connection.
The Decisions Behind It
Where the state goes decides the architecture. Self-hosted software keeps state in one of three places, and identifying which one you are dealing with settles the design. If the tool owns a storage format and writes to a path, it needs a Volume. If it speaks PostgreSQL, it can have a managed Database and become stateless itself. If the state is only files, a Bucket is enough. n8n defaults to SQLite on disk, so the volume is the honest answer here, and a tool that outgrows SQLite usually accepts an external Postgres instead.
Which version runs is a decision. The reference makes it. stable follows n8n’s released line, so a restart can bring a new version with it; pinning an exact version instead makes every upgrade a deliberate edit, and an untagged reference means latest, which is the one form that surprises you. Either way the mutable thing and the durable thing are separate components here: the task is replaced on every apply, and the volume it mounts is not.
The architecture generalises, the three facts do not. Uptime Kuma is the same architecture with image louislam/uptime-kuma:2, port 3001, and its volume at /app/data. Excalidraw keeps nothing at all, so it drops the volume and runs as a container behind the load balancer alone. Three fields change and the structure survives, which is what makes this one worth learning once.
The port, the data directory, and the health endpoint come from n8n’s own documentation, not from Spawned. Reading those three facts out of a project’s docs is the skill that makes self-hosting anything routine.
The Limits
A tool on a volume is a single writer. Nothing here prevents you from running several tasks, but software that assumes it owns a SQLite file will corrupt it if two copies mount the same volume and both write, so this architecture scales by making the task bigger rather than by making more of them. That is the practical ceiling, and it is the reason tools offer an external database once they expect to be run seriously.
The other limit is inherited. You are running someone else’s release, so the upgrade path, the configuration surface, and the security posture are theirs. What you control is where it runs, what it can reach, and what happens to its data, which is exactly what this file describes. Anything sensitive in its configuration should not sit in env, and Self-Hosted Open WebUI picks up from there.