2026-04-10 · infrastructure
Thirteen Services, One VPS
Five applications, thirteen Docker containers, seven domains, two CPUs, eight gigabytes of RAM. A tour of the topology that runs agency186.com and everything behind it.
The Box
One Hostinger VPS. Ubuntu 24.04. Two CPUs. Eight gigabytes of RAM. Ninety-six gigabytes of disk. That is the entire compute footprint for five applications, thirteen containers, and seven live domains. It serves the marketing site at agency186.com, the API at api.agency186.com, a custom CRM at shane.traveltamers.com, a public group-trip browser at groups.traveltamers.com, an automation hub at automations.traveltamers.com, a local LLM UI at llm.traveltamers.com, and a Traefik ingress that routes all of it.
The reflex when you describe a system like this is to assume it must be a mess. It is not. It is small on purpose. Every service on the box earns its place by being directly reachable on an internal Docker network and by being cheap enough in memory that the aggregate stays under the ceiling.
The Topology
Internet
|
[ Traefik :80/:443 ] auto-SSL, 7 domains
|
[ proxy network ]
/ | \ \
[ tt-fresh ] [ nexus-web ] [ groups ] [ n8n ]
| [ nexus-api ] | |
[ tt-api ] | | |
| [ nexus-net ] | |
| / | \ | |
| [nexus-db][redis][worker] |
| |
+--------[ internal network ]-------+
|
[ postgres (shared) ]
- tt_fresh_db
- groups_db
- n8n
|
[ ollama (host) ]
llama3.2, qwen2.5
~12 tokens/sec, CPU only
Traefik is the front door. It terminates TLS via Let's Encrypt, routes by Host header, and discovers new services from Docker labels the moment a container comes up. No nginx config files to hand-edit, no separate certbot cron. When I add a service, I add three labels to its compose file and the route is live after the next deploy.
Behind Traefik sit two Docker networks. The proxy network is the ingress path — every service Traefik needs to reach joins it. The internal network is the backplane — shared PostgreSQL, n8n, and anything that needs to talk to them without being public. A service that does both (like the Nexus API) joins both networks. Nothing public-facing talks directly to the database.
Why One Box Is Enough
The temptation with a multi-app platform is to split it across machines for isolation. That trades one class of problem for another. On a single box with Docker, process isolation, restart policies, and bind-mounted secrets already give you enough separation for a one-person shop. What you lose is the illusion of redundancy. What you gain is a single place to check, a single place to back up, and a single place to tail logs.
The five apps on this box are: TravelTamers-Fresh (an Astro static site plus a Hono API container), Nexus CRM (React frontend, Fastify backend, its own PostgreSQL, Redis, and a BullMQ worker — five containers in total), the Groups app (Express plus EJS), n8n (workflow automation), and the old static site on shared hosting that is being phased out. Three PostgreSQL databases live on the shared postgres container: tt_fresh_db, groups_db, and the n8n internal DB. Nexus CRM runs its own isolated postgres because its schema evolves independently and I do not want its migrations to touch anything else.
Seven Domains, One Router
Every domain on the box resolves to the same VPS IP. The differentiation is entirely in Traefik's routing labels. A representative label set on the tt-api container looks like:
traefik.http.routers.tt-api.rule=Host(`api.agency186.com`) traefik.http.routers.tt-api.entrypoints=websecure traefik.http.routers.tt-api.tls.certresolver=letsencrypt traefik.http.services.tt-api.loadbalancer.server.port=3100
Four labels. That is the entire public contract for a service. The same pattern repeats on every container that needs a public hostname. Certificate renewal is automatic. The only manual step is adding the DNS A record at the registrar.
The Observability Gap
A snapshot of what was running was the missing piece for months. With thirteen containers and dependencies that cross three networks, "what is deployed right now" was a question I answered by SSH-ing into the box and running docker ps. When something misbehaved, I did not always know whether it was misconfigured or simply not wired.
The fix was writing the system snapshot by hand — a single Markdown document that names every container, every database, every domain, every external API, and every known-good data flow. It lists what is live, what is configured but not firing, and what still needs wiring. It is not automated. It is deliberate. The act of writing it down exposed three services with missing environment variables that had been silently no-ops for weeks. A dashboard would have caught the symptom. Writing the doc caught the cause.
The Nervous System
n8n is the part of the platform that is hardest to explain and the easiest to underestimate. It runs as one container, owns its own database on the shared postgres, and is responsible for every cross-system workflow that does not belong inside an individual app. When a contact form submits on agency186.com, the Hono API writes the record, but n8n is what opens the Slack channel, sends the welcome email through Resend, syncs the lead into the CRM, and logs the analytics event. When the morning social media pipeline runs, n8n orchestrates a chain that walks from a Slack feed through Ollama, Claude, Perplexity, and FLUX before dropping drafts into a review folder.
Putting this logic in n8n instead of in application code is the choice that made the platform tractable for one person. Every workflow is a node graph you can open, inspect, and rerun. When something breaks, I am debugging a visible pipeline, not digging through a stack trace in a queue worker. The cost is that n8n is now a load-bearing dependency. If it is down, the nervous system is down.
The Lesson
The hard-learned lesson is mundane and expensive: a service that is "configured" is not a service that works. The snapshot lists several systems as "live" and several more as "configured but needs wiring" — the social media pipeline needs the daily cron enabled, the email sequences need the Resend key added to the tt-api container, the n8n webhooks need SLACK_BOT_TOKEN and OLLAMA_BASE_URL set inside the container's environment. Every one of these was "done" in my head before I wrote the doc. None of them were actually firing. The gap between "I wrote the code" and "the code is executing in production against real inputs" is the most reliable source of bugs I have in this system, and the only thing that closes it is an end-to-end test that exercises the full chain with real credentials and real side effects.
One box is enough. Thirteen containers are not too many. The hard part is not the topology. The hard part is keeping the map honest.