NickaelDeveloper & Product Builder

Case study · Platform & hub

nickael.com

What it is

A personal hub at nickael.com, and the self-hosted platform underneath it where every product I build gets its own subdomain.

Why I built it

I wanted one address for everything I make — and a foundation where adding the next product is a deploy, not another server to buy and babysit.

Status
Live
Year
2026
Role
Everything — architecture, infrastructure, build, operations
Stack
Astro · TypeScript · Appwrite · Docker · Traefik

01

Context

I already ran a VPS with an Appwrite installation on it, serving an existing project. What I did not have was anywhere to put the next thing.

Every side project I started ended up somewhere different — a different host, a different account, a different way of deploying, each with its own small tax to remember. Nothing connected to anything. There was no single address I could give someone that meant “this is what I build”.

So the goal was never really “build a portfolio site”. It was to build the place the portfolio would sit on top of.

02

The problem

The obvious move was to put the new work inside the Appwrite I already had, as another project. I did not want that. A mistake in one product should not be able to reach another — not the database, not the storage, not the runtimes. Shared tenancy makes that guarantee impossible to state honestly.

So the requirement became specific: a second, complete, independent Appwrite platform — its own database, Redis, workers, executor, storage, Sites and Functions — with nothing shared but the machine itself.

03

Constraints

  • One VPS, one public IP, one Docker daemon — all of it already in use by the existing installation.
  • A Traefik was already running as the machine’s reverse proxy, holding ports 80 and 443.
  • Appwrite ships its own Traefik, which wants those same ports.
  • The existing installation was serving real traffic and could not break.
  • Solo operator. Whatever I built, I would be the one debugging it at midnight.

That last constraint did more work than the others. It ruled out anything clever enough that I would not remember how it worked six months later.

04

My role

Everything: deciding it should exist, choosing the architecture, running the install, diagnosing what broke, and operating it now that it is up. There was no one to hand a layer to.

The work was AI-assisted throughout, and it is worth being precise about where the line falls. Agents were good at generating configuration, recalling flags, and producing candidate explanations quickly. They were not the ones who decided to keep Appwrite’s internal proxy, noticed that a container answering to the wrong hostname explained a symptom four layers away, or judged which of three plausible explanations was worth testing first.

05

Decisions

Keeping Appwrite’s internal Traefik

Two reverse proxies wanting the same ports is a conflict you resolve on purpose or suffer by accident. I could strip Appwrite’s internal Traefik and rewire the stack to the external one, or keep Appwrite near its official shape and put the external proxy in front.

I kept it. That internal proxy is not decoration — it routes the console, the API, Sites, Functions, previews and custom domains. Removing it means owning every one of those rules myself, forever, re-deriving them at each upgrade. Instead the external proxy became a pure edge: public entry, TLS, and which stack a hostname belongs to. Everything below the hostname stayed Appwrite’s problem.

A second platform, not a second project

Appwrite has a perfectly good notion of projects inside one installation, and using it would have taken an afternoon. I rejected it because it gives isolation of data but not of failure: one bad migration, one runaway worker, one upgrade gone wrong, and both products are down together. Two stacks cost more setup once; shared tenancy costs risk continuously.

Wildcard TLS at the edge, not per-site ACME

Appwrite can request its own certificates per domain over HTTP-01. I turned that off and issued wildcards at the external proxy over DNS-01 instead. One certificate covers a whole namespace, new subdomains need no issuance step at all, and the HTTP-01 challenge — which needs the very routing you are still setting up — stops being on the critical path.

06

How it works

How a request reaches a product InternetTraefik (external):80 / :443 · TLS · wildcardsAppwrite ATraefik (internal) · loopback onlyAPI · Sites · Functionsother projectAppwrite BTraefik (internal) · loopback onlyAPI · Sites · Functionsnickael.com · *.nickael.comShared: the machine, the Docker daemon, one public IPHow a request reaches a product InternetTraefik (external):80 / :443 · TLS · wildcardsAppwrite ATraefik (internal) · loopback onlyAPI · Sites · Functionsother projectAppwrite BTraefik (internal) · loopback onlyAPI · Sites · Functionsnickael.com · *.nickael.comShared:the machine, the Docker daemon, one public IP
How a request reaches a product

A request arrives at the external proxy, which terminates TLS and decides — by hostname alone — which installation it belongs to. It forwards to that stack’s internal Traefik on loopback, and from there Appwrite does its own routing to the API, a Site, or a Function.

Technical detailThe one setting that makes or breaks it

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 at all.

traefik · dynamic
loadBalancer:
  passHostHeader: true      # sem isso: Host: 127.0.0.1
  servers:
    - url: "https://127.0.0.1:<PORT>"

The internal proxies bind to loopback only, so nothing but the external one can reach them. The public surface of the machine stays a single process.

07

Building it

The second stack started as a copy of the first, which is exactly why it was dangerous. Two identical stacks on one Docker daemon share a namespace for things you stop thinking about: container names, hostnames, network names, volume names, internal secrets, local ports.

Each one has to be made unique deliberately. Miss any single one and you do not get an error — you get two stacks quietly sharing something they should not, and a symptom that surfaces somewhere unrelated.

Technical detailWhat has to be made unique
  • Compose project name — otherwise the two stacks fight over the same resource prefixes.
  • container_name AND hostname — these are different identifiers and changing one does not change the other.
  • Docker networks, application and runtimes alike, each created ahead of first boot.
  • Volumes, so storage and certificates never land in the same place.
  • Internal secrets, generated fresh rather than copied with the file.
  • Loopback port bindings, so the two internal proxies do not collide.

08

What broke

A test Site deployed, built, started — and then returned nothing at all. DNS resolved, TLS completed, the request went out, and zero bytes came back until timeout. No log said why.

A timeout is the least informative failure there is, so the first move was to turn 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 single comparison cleared four layers at once. An application-level error proved the request reached Appwrite — so DNS, TLS, the external router and the wildcard rule were all already fine. The failure only appeared once a real Site tried to reach a runtime.

From there I walked down. The runtime container was created on demand; its logs showed the server starting. Reaching it directly without credentials returned an authorization error — proof it was alive and listening. Reaching it with the right secret returned the full page. Everything worked. So I stopped testing from the host and tested from inside the executor, which is the component that actually has to make that call in production.

host     → runtime    ✅ funciona
executor → runtime    ❌ timeout

That was the turn. Not the application, not Appwrite, not the framework, not Traefik, not TLS. Docker networking.

The root cause

I had given the second stack a unique container name and assumed that covered it. It did 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. The lookup found the wrong one, and the first installation’s executor dutifully joined a network that belonged to the second.

Executor A                    Executor B
├─ appwrite-primary           └─ appwrite-secondary
├─ runtimes-primary              (faltava runtimes-secondary)
└─ runtimes-secondary  ← errado

Fixing the hostname repaired executor-to-runtime and immediately broke Appwrite-to-executor, which was still configured to reach the old name. The symptom changed from a hanging timeout to a fast 500 — and that was progress worth noticing, not a regression.

And then the domain

Pointing nickael.com at the platform had its own sequence. The apex cannot carry a CNAME — that is a DNS rule, not an Appwrite one — so it took an A record, which Appwrite accepted. Certificate issuance then failed anyway, and the honest answer is that I chased the wrong suspect for a while: DNS propagation looked guilty, and was not. The actual cause was an entry missing from the external proxy’s dynamic configuration. The request never reached anything that could answer the challenge.

09

Where it landed

Two complete Appwrite platforms now run on one machine — two databases, two Redis, two executors, two runtime networks, two sets of Sites and Functions — behind a single public proxy, without Kubernetes and without forking the official stack.

This site is the first thing living on the new one. It is a static Astro build, deployed to Appwrite Sites, served from that platform at the apex domain. The part that matters is not the site — it is that the next product needs none of this work again. It gets a subdomain and a deploy.

The website was the easy part. The interesting problem was building somewhere every future product could live.

10

What I would change

  • Make every identifier unique before the first boot, not after the first symptom. A five-minute checklist would have saved the longest debugging session in the project.
  • Test from where production calls, earlier. From the host the runtime was reachable; from the executor it was not. Only the second test described reality, and I ran it late.
  • Distrust the layer that reports the error. Twice the message named a component that was working fine — once the Site, once DNS.
  • Write the runbook while doing it, not afterwards. Half the value of this project is knowing how to do it again, and that nearly evaporated.