Two Appwrite instances on one VPS
Running two complete, independent Appwrite 2.0 installs on a single machine behind one external Traefik — and the Docker hostname collision that took a nine-layer teardown to find.
I needed more than one backend. Separate products, separate data, separate blast radius — a mistake in one should not be able to reach the other. The obvious answers were a second VPS or a move to Kubernetes. Both solve isolation by spending: one on monthly cost, the other on operational complexity I would carry forever.
So I asked a narrower question: can two complete Appwrite installs share one machine without sharing anything that matters? The answer turned out to be yes — but the reason it is hard has nothing to do with Appwrite, and everything to do with what two identical stacks do to each other inside one Docker daemon.
The decision that shaped everything
Appwrite ships with its own Traefik. My VPS already had one, serving everything else on the box. Two reverse proxies wanting ports 80 and 443 is a conflict you have to resolve deliberately, and there were two ways out.
- Strip Appwrite’s internal Traefik and rewire the whole stack to the external one.
- Keep Appwrite close to its official configuration and put the external Traefik in front of it.
I took the second. Appwrite’s internal Traefik is not decoration — it routes the console, the API, Sites, Functions, previews and custom domains. Tearing it out means owning every one of those routing rules myself, forever, across every upgrade. The external proxy instead became a pure edge: public entry, TLS, and which stack a hostname belongs to. Everything below the hostname stayed Appwrite’s problem.
Internet
│
┌──────▼──────┐
│ Traefik │ :80 / :443
│ externo │ TLS · DNS-01 · wildcards
└──────┬──────┘
┌────────┴────────┐
▼ ▼
127.0.0.1:PORT_A 127.0.0.1:PORT_B
│ │
┌────────▼───────┐ ┌───────▼────────┐
│ Traefik interno│ │ Traefik interno│
│ Appwrite A │ │ Appwrite B │
└────────┬───────┘ └───────┬────────┘
API · Sites · Fn API · Sites · FnWhat fronting Appwrite actually requires
The internal router picks its rule from the original hostname. If the external proxy forwards without preserving the Host header, the internal Traefik sees the loopback address instead of the real name — and Sites, Functions and custom domains quietly stop resolving to anything. One flag decides whether the whole arrangement works.
loadBalancer:
passHostHeader: true # sem isso, o Traefik interno vê Host: 127.0.0.1
servers:
- url: "https://127.0.0.1:<PORT>"The internal Traefiks then bind to loopback only, so nothing but the external proxy can reach them. Appwrite’s own ACME is switched off — certificates are issued once, at the edge, over DNS-01, which also sidesteps the HTTP-01 challenge entirely and lets one wildcard cover a whole namespace.
The bug that took nine layers to find
The second install came up. A test Site deployed, built, and then did nothing. DNS resolved, TLS completed, the request went out — and zero bytes came back until timeout. Nothing in any log said why.
A timeout is the least informative failure there is, so the first move was to convert it into a more specific one. I requested a hostname that did not exist:
random.sites.example-b.com → rule_not_found (rápido)
hello.sites.example-b.com → timeout (trava)That one comparison eliminated four layers at once. An application-level error proved the request reached Appwrite, which means DNS, TLS, the external router and the wildcard rule were all correct. The failure only appeared when a real Site tried to reach a runtime — so the problem lived past everything I had built and inside the part I had not.
From there I walked down. The runtime container was being created on demand. Its logs showed the Next.js server starting. Reaching it directly without credentials returned an authorization error — proof the process was alive and listening. Reaching it with the correct runtime secret returned the full HTML.
artifact ✅ Open Runtimes ✅ Next.js ✅
runtime ✅ porta ✅ conteúdo ✅Everything worked. The application was fine, the build was fine, the content was fine. So I stopped testing from the host and tested from inside the Executor container — the one component that actually has to talk to the runtime in production.
host → runtime ✅ funciona
executor → runtime ❌ timeoutThat was the turn. Not application, not Appwrite, not Next.js, not Traefik, not TLS. Docker networking.
Root cause: a hostname collision
The second Executor had no network in common with its own runtimes. Inspecting the first install explained why — the second install’s runtime network had been attached to the first install’s Executor.
Executor A Executor B
├─ appwrite-primary └─ appwrite-secondary
├─ runtimes-primary (faltava runtimes-secondary)
└─ runtimes-secondary ← erradoI had given the second stack a unique container_name and assumed that was enough. It was not. The container kept the stock hostname, and the Executor attaches itself to its runtime network by looking itself up by that hostname. Two containers answered to the same name on the same daemon, and the lookup found the wrong one — the first install’s Executor, which dutifully joined a network that did not belong to it.
Giving the second Executor a unique hostname fixed the Executor-to-runtime path — and immediately broke Appwrite-to-Executor, because the platform was still configured to reach the old name. The symptom changed from a timeout to a fast 500.
Pointing the executor host variable at the new name, and recreating only the services that read it, closed the loop. Both installs have been serving since.
What generalizes
- Test from where production actually calls. From the host the runtime was reachable; from the Executor it was not. Only the second test described reality.
- Do not redeploy without evidence. The build had already proven itself end to end — a new deploy could not have fixed a networking problem, and would only have added variables.
- Trust current state over old logs. An ACME log showing failure said nothing about the certificate actually installed; the served certificate is the source of truth.
- Isolation is per-identifier, not per-install. Container names, hostnames, networks, volumes and internal secrets each have to be made unique on purpose.
The result is two complete Appwrite installs — two databases, two Redis, two Executors, two runtime networks, two sets of Sites and Functions — coexisting on one machine, without Kubernetes and without forking the official stack. This site runs on it.