I build and run Pulsenote by myself. It's a multi-tenant transactional email API — you POST a payload, it gets delivered, you get told what happened to it. Behind that sentence sits a NestJS monorepo, a Kubernetes cluster, ArgoCD, Vault, Terragrunt and two client SDKs. It sat on two git hosts until recently, which is one of the reductions described at the end.
That's a lot of surface for one person, and this isn't a post that presents it as a triumph. Some of it is load-bearing. Some of it is me enjoying infrastructure more than I enjoy marketing. This is my attempt to separate the two. If you're deciding how much platform to build before you have customers, the useful part is probably the section near the end where I list what I'd cut.
What the product actually has to do
Every decision below traces back to five requirements. Writing them down is the only way to judge whether the stack is justified or indulgent.
- Accept a send request fast and never lose it, even when the provider is slow or throttling. Accept path and send path must be separate.
- Send through a real provider with real reputation management. Domains, DKIM, SPF, bounces, suppression — the product, not plumbing.
- Track what happened after the send. Webhooks arrive on the outside world's schedule and have to be ingested independently of everything else.
- Be multi-tenant from day one. API keys, per-tenant domains and templates, data isolation. Retrofitting tenancy is a rewrite; building it in is a schema decision.
- Have a dashboard, an admin surface, and a public site.
Requirements 1–4 are why there is a queue, a worker, a tracker and a domain model. Requirement 5 is why there are three frontends. None of that is Kubernetes yet — hold that thought.
The app layer: one monorepo, several deployables
Pulsenote is a NestJS native monorepo: one repo, one toolchain, several independently deployable apps sharing two libraries.
Apps: api-gateway, email-worker, delivery-tracker, dashboard-api, admin-api, web (Next.js 16), backoffice (Next.js 14), landing.
Libs: common (shared DTOs, guards, config) and database (TypeORM entities and migrations against Postgres).
This is deliberately not microservices: no service mesh, no per-service database, no independent versioning, no internal contract tests. Nor is it one monolith handling both a burst of inbound API calls and a long tail of send retries. The split follows failure and scaling boundaries, nothing else:
client
│ POST /v1/notifications (X-API-Key)
▼
┌──────────────┐ validate, authenticate tenant,
│ api-gateway │ persist, enqueue, return 202
└──────┬───────┘
│ publish
▼
┌──────────────┐
│ LavinMQ │ durable queue — the buffer between
└──────┬───────┘ "accepted" and "actually sent"
│ consume
▼
┌──────────────┐ render template, apply tenant
│ email-worker │ domain/identity, hand off to provider
└──────┬───────┘
│ send
▼
┌──────────────┐
│ AWS SES │ ──────► recipient
└──────┬───────┘
│ delivery / bounce / complaint events
▼
┌──────────────────┐ ingest webhooks, reconcile
│ delivery-tracker │ status back onto the message
└──────┬───────────┘
│
▼
┌──────────────┐ ┌───────────────┐ ┌─────────────┐
│ Postgres │◄───────│ dashboard-api │ │ admin-api │
└──────────────┘ └───────┬───────┘ └──────┬──────┘
│ │
web (N16) backoffice (N14)
The monorepo's value is that common and database are one source of truth. Add a column, and one migration plus one entity change propagate to every app at build time, with the type checker telling me what broke. In a polyrepo I'd be publishing an internal npm package to myself at 11pm. Cross-repo coordination cost is what kills a solo maintainer.
Separate deployables matter because api-gateway and email-worker have different profiles. The gateway is latency-sensitive and bursty; the worker is throughput-oriented and must back off, retry and stall without ever making an API call slow. As one process, a single SES throttling episode would degrade signup, dashboard and API traffic simultaneously.
The two Next.js versions (16 for web, 14 for backoffice) are not a design decision. That's just what an unmigrated internal tool looks like. Leaving it in rather than tidying it up for the article.
The platform layer: DOKS, ArgoCD and why GitOps as one person
The cluster is DigitalOcean Kubernetes in AMS3, with managed Postgres and LavinMQ alongside it. It ran in LON1 until recently; moving it was a day of work and is a story of its own. AWS provides the organization structure and SES; Cloudflare does DNS; Sentry does errors.
On top sits an ArgoCD app-of-apps setup: a bootstrap argocd chart, an argo-config chart holding ApplicationSets, and a core-apps umbrella chart bundling external-secrets, Traefik, cert-manager, Vault, external-dns and a CI runner. The application deploys through its own ApplicationSet using a published nestjs Helm chart.
Why GitOps when there is no team to coordinate with? It's usually sold as a collaboration mechanism, and I have nobody to collaborate with. The honest answer: I'm not defending against a colleague's mistake, I'm defending against my own memory. Six weeks after touching something I am effectively a new team member with no context. Declarative cluster state in git means "what is running and why" is a file, not an archaeology session with kubectl, and recovery is a rebuild rather than a recall exercise — which matters far more when nobody else can rebuild it for you.
Secrets go through external-secrets backed by Vault, with the app reading paths like pulsenote/{db,mq,app,aws}. Two simpler options lost out. Plain env vars in the manifests: no, manifests live in git and I want them boring. Sealed secrets: tempting, one fewer component to run, but a sealed secret is ciphertext committed to a repo, so rotating means re-sealing and re-committing every consumer. With provider credentials and per-tenant sending identities in play, I wanted rotation to be a Vault operation, not a git commit.
That reasoning holds. Whether it justifies operating a Vault instance solo is a different question, and I'll come back to it.
Terragrunt: what it buys, and the bug I should confess
Infrastructure is a Terragrunt monorepo over about fifteen reusable Terraform modules — AWS organization, DOKS, SES pieces, Vault, LavinMQ, Cloudflare DNS. State lives in S3, provider config derives from the directory path, and each cloud gets its own tree of units.
What Terragrunt buys over plain Terraform modules is DRY-ness of config, not of code. Backend blocks, provider blocks and shared inputs are declared once and inherited down the tree, so every new unit is a small terragrunt.hcl naming a module and its inputs — no copy-pasted backend stanza that drifts. With one AWS org and a second account for production, that inheritance made "add prod later" a plausible sentence rather than a rewrite — which is what it turned out to be when production went live.
The cost is a second layer of indirection and a second tool's failure modes. And here's the concrete embarrassment, because a post like this is worthless without one.
Some of my DigitalOcean units point their source at a local absolute path on my old laptop:
terraform {
source = "/Users/<me>/<some-other-project>/infra/tf_modules/..."
}
A machine-specific path, in an otherwise portable repo, sitting next to a perfectly good in-repo tf_modules/ directory. Those units will not plan on any other machine — a CI runner, a fresh laptop, or mine after a reinstall. It's what happens when you move fast during a migration and it works locally, so the feedback loop that would catch it never fires.
Two lessons. First: if your infra only plans on one machine, it isn't infrastructure-as-code, it's a very elaborate shell history. Second: nobody caught it because nobody else was ever going to run it. That's the tax of solo work — no second machine means no second opinion. A trivial CI job running terragrunt plan on every unit from a clean checkout would have caught it in a day.
The same migration left ArgoCD manifests and DO units still pointing at an old GitLab home for the infra repo. Which brings me to the git hosts.
Two git hosts, and whether the split is worth it
The application lived on GitLab; the infrastructure and both SDKs on GitHub, provisioned by Terraform — the repos themselves are Terraform resources, which I like: a new SDK repo is a module instantiation, not a click-through.
How did this happen? Historically. The app came from a GitLab-centric consulting context with GitLab CI already wired up and a runner in the cluster. That pipeline is migration-first — database migrations run as their own stage before any app image is rolled out, so a deploy can never land code that expects a column the database doesn't have yet. It's the single CI rule I'd port to any stack. The SDKs are public developer artifacts, and GitHub is where that audience searches, stars and files issues, and where the npm and Packagist links point. Infra landed on GitHub as part of a migration that isn't finished.
The part I'd defend: public developer artifacts belong where developers are. Nobody discovers a PHP SDK in a private GitLab group.
The part I wouldn't: everything else is friction. Two CI systems, two permission models, two places to look, and cross-references silently pointing at a repo location that moved. Starting today I'd put the app and infra on the same host as the SDKs and eat a one-time migration. The split only pays rent on the SDK repos.
Update: I took my own advice. The application repository and its CI have since moved to GitHub alongside everything else. One thing did not move — container images still push to the GitLab registry, because that part was working and the migration had a scope. So the split survives in exactly one place, at the layer where it costs nothing, which is roughly where it should have been all along.
The SDK strategy: generated TypeScript, hand-written PHP, and a drift test
This is the part of the stack I'm happiest with, and the most reusable idea here.
Both SDKs cover the same surface: the data plane, the X-API-Key endpoints for notifications, templates and domains — 17 operations. JWT account-management endpoints are deliberately out of scope; they belong to the dashboard, not to a customer's integration. Deciding that explicitly stopped a lot of scope creep.
The Node SDK is generated. openapi-typescript-codegen runs against the OpenAPI spec via npm run generate. Everything under the client is machine output; only src/client.ts and src/main.ts are hand-written wrappers. When the spec moves I regenerate and the types change. Cost: ergonomics are whatever the generator gives you, and the diffs are large and unreadable.
The PHP SDK is hand-written. PSR-18 transport, named arguments in, typed models out, PHP 8.1+, plus an auto-discovered Laravel integration — service provider, facade, and a pulsenote notification channel so Laravel users can do the idiomatic thing instead of learning my client. Generated PHP wouldn't have given me any of that. In an ecosystem where most of your users are on one framework, the framework integration is the SDK.
The risk with a hand-written client is drift: the API grows an endpoint and the SDK quietly doesn't have it. So the PHP repo carries a spec-coverage test. Each public operation is annotated:
#[Operation('POST', '/v1/notifications')]
public function send(
string $to,
string $subject,
?string $templateId = null,
// ...
): Notification {
// ...
}
and the test reflects over the SDK, collects every #[Operation], loads the committed openapi/pulsenote-api.json, and diffs the two sets in both directions:
public function testSdkCoversEverySpecOperation(): void
{
$spec = $this->operationsFromSpec(); // method+path from openapi JSON
$sdk = $this->operationsFromAttributes(); // method+path from #[Operation]
$this->assertSame([], array_diff($spec, $sdk), 'Endpoints in the spec but missing from the SDK');
$this->assertSame([], array_diff($sdk, $spec), 'SDK methods pointing at endpoints the API no longer has');
}
The second assertion matters as much as the first: it catches methods that outlived the endpoint they call. make spec refreshes the committed JSON, so updating the spec is a deliberate act that shows up in a diff and turns the test red until the SDK catches up.
The pattern is cheap — one test file and one attribute — and it converts "I hope the SDK is current" into a build failure. If you maintain a hand-written client against your own API, this is the highest-leverage thing in this post. You don't need codegen to get codegen's main guarantee; you need a test that fails when the two drift.
(The one plug in this post: all of the above serves Pulsenote, which has a free tier if you'd rather see the output than the diagram.)
The self-critical part: is this too much platform for one person?
Yes. Parts of it plainly are, and I want to be precise about which parts rather than doing a performative "maybe I over-engineered it" and moving on.
The free tier is 1,000 emails a month, and the product is newly launched with no traction to speak of. Against that, I operate a Kubernetes cluster, ArgoCD with an app-of-apps hierarchy, Vault plus external-secrets, cert-manager, Traefik, external-dns, a self-hosted CI runner, and a Terragrunt monorepo over a multi-account AWS organization. It was one component longer until I deleted the self-hosted auth server and moved authentication into the application — the single biggest reduction in this list, and the one I should have made sooner. Every one has upgrades, CVEs, breaking chart changes and a 2am failure mode. There is no rotation. I am the rotation.
Here's the distinction I've landed on, and it's the one thing from this post I'd keep: complexity that is the product pays for itself; complexity that is around the product usually doesn't.
For an email API, the things that look like infrastructure to an outsider are the product:
- Multi-tenancy — API keys, per-tenant domains and templates, isolation — is a feature customers buy, not a deployment detail.
- Deliverability plumbing — SES identities, DKIM/SPF, bounce and complaint ingestion, suppression — is exactly what someone pays to not build.
delivery-trackerexists because "did it arrive" is the question the product answers. - The durable queue between accept and send is a correctness requirement. Without it, a provider hiccup becomes lost mail.
None of that is over-engineering. Strip it out and I have a thin wrapper over SES with no reason to exist.
The hosting of it is another matter. Kubernetes, ArgoCD, Vault and Terragrunt are not the product, they're the substrate — and they're where most of my operational hours have gone. The absolute-path bug above cost me nothing in customer value and a real evening of my life. Labelled clearly as an impression rather than measured data: the platform layer has taken meaningfully more of my time than the app layer, and it's not the part customers can see. The recurring bill is dominated by the cluster's node pool and managed Postgres, both of which exist whether or not anyone sends mail — my costs are provisioned, not usage-driven, which is exactly backwards pre-traction.
The counter-argument, which I do believe: I knew this stack cold before I started. My background is platform work, so standing up DOKS and ArgoCD was faster for me than learning a PaaS's opinions. The trap in build-in-public posts is that the author's prior expertise silently subsidises the recommendation. So: don't copy this stack because it worked for me — copy it only if you already have these scars.
What I'd cut if I started over
Concretely, unsentimentally, in the order I'd cut them:
- Kubernetes, initially. For a pre-traction product with a handful of long-running processes, managed containers (App Platform, Fly, ECS, whatever) would run the same eight deployables with a fraction of the operational surface. Move to Kubernetes when a real constraint demands it, not on day one.
- Self-hosted Vault. Rotation is a genuine requirement and I'd keep external-secrets as the interface, but point it at a managed secrets store. Same guarantee, no upgrade path to own.
- Self-hosted auth (FusionAuth). For a B2B email API, auth is table stakes, not a differentiator. Running my own identity provider is the definition of undifferentiated heavy lifting.
- The second git host. Consolidate app and infra onto the SDKs' host. Keep the SDK repos where developers find them; stop paying for two CI systems.
- The self-hosted CI runner. It exists because of the cluster. Remove the cluster and it removes itself.
-
One of the two admin surfaces, for now.
admin-apiplusbackofficeon an older Next.js is real maintenance for an audience of exactly one person: me. Database access and a couple of scripts would have covered it early on.
Two of those have since happened: the self-hosted identity provider is gone, replaced by auth inside the application, and the app repository has moved to sit with the SDKs. Neither was hard once written down — which is an argument for writing the list rather than for having better judgement.
What I'd keep without hesitation: the NestJS monorepo with separate deployables, the accept/queue/send/track split, Terraform for anything cloud-shaped, and the SDK drift test.
And the least glamorous line here: I built the platform before I had users to justify it, because the platform was the part I already knew how to do and shipping to strangers is the part I didn't. That's not an architecture mistake. It's a procrastination pattern that happens to compile.
—
Top comments (0)