Self-hosted platform
One AWS box. No managed PaaS.
Every site, app, mailbox, dashboard, and Git repository referenced on this site runs on infrastructure I designed, built, and operate end-to-end — 25 containers across independent Compose stacks behind a single nginx edge, on one EC2 instance. When it breaks at 2 a.m., I'm the one who fixes it.
Overview
This is the platform underneath everything else in my portfolio. Rather than renting a managed service per concern — one host for the sites, another for mail, a SaaS for analytics, a third for metrics — it all runs on a single AWS EC2 instance under Docker Compose, deliberately.
The constraint is the point. Operating a full stack on one box with no PaaS safety net means owning the parts that managed platforms normally hide: TLS issuance and renewal, DNS, reverse-proxy routing, SMTP deliverability and authentication, container networking, disk pressure, and backup. Those are precisely the areas where production incidents originate, and where debugging experience is hard-won.
It has also been genuinely operated, not merely stood up — including a full rebuild after a catastrophic instance loss, which I wrote up separately.
Architecture
The organizing idea is independent Compose stacks sharing one external bridge network.
Each project — the Evomedia site, evo.ehs, SWAG Estimates, DocketMail, analytics, mail, monitoring —
owns its own docker-compose.yml and deploys on its own schedule. They meet only on a shared
Docker network named web, which lets a single reverse proxy reach every service by container
name without any of them being coupled to each other.
-
Single nginx ingress — one edge container terminates TLS on 443, routes by
server_name, redirects HTTP to HTTPS, and passes WebSocket upgrades through. Adding a new site is a server block, not a new host. -
Per-service TLS — Let's Encrypt certificates issued and auto-renewed via Certbot using
the webroot method, with each HTTP block serving
/.well-known/acme-challenge/so renewals succeed without downtime. Multi-SAN certificates cover the subdomain groups. - CDN in front — CloudFront sits ahead of the main site for edge caching, with explicit cache-control rules: HTML always revalidates so a deploy is never masked by a stale document, while content-hashed assets are cached immutably for a year.
- DNS — Route 53 hosted zones covering the apex domains and every subdomain, including the MX, SPF, DKIM, and DMARC records the mail stack depends on.
- Deployment — each stack is deployed by zscripts, which preserves environment files across destructive redeploys, verifies the shipped build version, and reloads the edge proxy so it re-resolves the new container.
What's running
25 containers, grouped by role:
- Web & applications — the Evomedia site, evo.ehs (app, PostgreSQL, and a docs service), SWAG Estimates (web, API, and two PostgreSQL instances), DocketMail (app and database), a landing service, and a small contact-form API that relays through the local mail stack.
- Mail — a full self-hosted mail server handling SMTP on 25, submission on 465 and 587, and IMAP on 993, running Postfix and Dovecot with Rspamd filtering and Fail2ban, plus Roundcube webmail and an admin interface. Outbound mail relays through Brevo with DKIM/SPF/DMARC alignment so messages actually authenticate rather than landing in spam.
- Source control — a self-hosted Gitea instance served over HTTPS, used as a public mirror for cloning.
- Analytics — self-hosted Umami with its own PostgreSQL backing store, so site analytics stay on infrastructure I control rather than a third-party tracker.
- Monitoring — Prometheus, Grafana, cAdvisor, node-exporter, nginx-exporter, and a Postfix exporter (detailed below).
Observability
Running without a managed platform means nothing tells you when something is wrong unless you build it. The monitoring stack covers four layers:
- Host — node-exporter for CPU, memory, disk, and network; disk pressure is the single most common failure mode on a one-box deployment, so it is watched closely.
- Containers — cAdvisor for per-container resource usage and restart behavior, which surfaces crash loops before a user reports them.
- Ingress — nginx-exporter for request rates and status-code distribution at the edge, making a spike in 502s visible as it starts.
- Mail — a Postfix exporter tracking queue depth and delivery outcomes, because silent mail failure is otherwise invisible until someone says they never got a reply.
Prometheus scrapes all of it; Grafana dashboards render it; email alerting escalates. Deploys additionally carry their own verification — the deploy tooling reads the build version out of the running container and fails loudly if it doesn't match what was just shipped.
Failures worth keeping
The most useful thing about operating your own platform is the specific, unglamorous failures it teaches you. A few that became engineering notes:
-
Container DNS is not public DNS. Adding a public resolver alongside Docker's internal
one at
127.0.0.11caused intermittent 502s — nginx load-balances across configured resolvers, and the public resolver cannot resolve internal container names. Roughly half of upstream lookups failed, at random. - Rate limiting behind a CDN needs the right key. Limiting on the direct client address throttles legitimate users when every request arrives from a shared CloudFront edge IP.
- A recreated container gets a new IP. The edge proxy caches the old one, so a deploy that skips a proxy reload produces 502s that look like an application failure but are pure DNS staleness.
- A certificate and a server block are two different things. A valid certificate on disk does nothing if no server block claims that hostname — the request falls through to the default server and is served the wrong certificate, producing a browser security warning while the application behind it is perfectly healthy.
- Stale HTML outlives a deploy. Content-hashed assets are safe to cache forever; the HTML that references them is not. Getting that split wrong makes a successful deploy look broken.
Each of these is written up in more detail in the engineering notes.
Technology stack
- Compute — AWS EC2 (Ubuntu 24.04 LTS), single instance, cost-aware sizing
- Containers — Docker & Docker Compose, independent stacks on a shared bridge network
- Ingress & TLS — nginx reverse proxy, Let's Encrypt via Certbot (webroot, auto-renewal)
- CDN & DNS — CloudFront, AWS Route 53
- Mail — Postfix, Dovecot, Rspamd, Fail2ban, Roundcube, Brevo relay, DKIM/SPF/DMARC
- Data — PostgreSQL (per-application instances)
- Source control — Gitea
- Monitoring — Prometheus, Grafana, cAdvisor, node-exporter, nginx-exporter, postfix-exporter
- Analytics — Umami (self-hosted)
- Automation — zscripts (PowerShell deploy, backup, and diagnostics)