For a month, a production system I own served new frontend code and new API code against backend functions that were thirty days stale. Every dashboard reported healthy. The containers were running. TLS was valid. Uptime was green.
The deploy had failed on its first step, and then succeeded at everything else.
That is the thing nobody warns you about when you decide to own your own infrastructure. The hard part is not standing the servers up. It is knowing, with evidence, that what is running is what you think is running. This article is the deployment playbook I use across Zesty, BidMorning, VEQO, Trax, DealGPT, and Mojo-360, covering AWS, self-hosted platforms like Dokploy and Coolify, and running services like Supabase and Convex yourself.
It is long because self-hosted deployment is genuinely a lot of small decisions, and the small ones are where the outages live.
Three tiers, and the honest case for each
Every deployment decision I make lands in one of three tiers. They are not a ladder you climb. They are choices with different bills.
Managed platform
Push to Git, they run it
Vercel, Netlify, Railway, Convex Cloud, Supabase Cloud
Self-hosted PaaS
Heroku experience, your server
Coolify or Dokploy on a VPS or cloud compute
Cloud primitives
You assemble it
AWS with RDS, S3, Secrets Manager, load balancer
The default should be managed. I say that as someone who runs a lot of infrastructure. A managed platform is not where you save money, it is where you save attention, and attention is the scarcest thing a small team has. If a product is early, if the revenue does not exist yet, if the team is two people, put it on a managed platform and spend the saved hours on the product.
Three pressures actually justify moving off it.
Cost shape. Not cost level, cost shape. Managed platforms price on usage dimensions that can decouple from your revenue: bandwidth, function invocations, database compute-seconds, row reads. When a product's usage grows faster than what customers pay for it, the bill becomes a business problem rather than an infrastructure one. Scraping and ingestion workloads hit this early, because they generate enormous background work for a small number of paying users.
Residency and compliance. This one is not negotiable when it applies. VEQO's canonical deployment region is AWS Asia Pacific in Mumbai, ap-south-1, because the data is Indian agricultural and livestock custody records and it stays in India. Once residency is a requirement, your options narrow to providers with a region there and the willingness to prove it.
A service you cannot get. Postgres with both PostGIS and pgvector, a Python inference service that needs its own compute boundary, a realtime gateway you control the reconnect semantics of. Managed platforms are opinionated, and eventually you need something outside the opinion.
If none of those three apply, staying managed is the correct engineering decision and "I want more control" is not an argument, it is a preference with a monthly time cost.
What a self-hosted PaaS actually gives you
Coolify and Dokploy occupy a genuinely useful middle. You get the developer experience of a managed platform on infrastructure you own: connect a Git repository, it builds a container, it runs it, it gets a TLS certificate, it manages environment variables, it can stand up Postgres or Redis as a one-click service, and it gives you logs and a deploy history in a UI.
The comparison people want is which one to pick, and the honest answer is that the choice matters less than they think.
- Broader feature surface and a larger community
- More built-in service templates out of the box
- More configuration to learn before it fits your shape
- Good when you want the platform to do more for you
- Leaner, closer to plain Docker Compose underneath
- Easier to reason about when a deploy misbehaves
- Fewer abstractions between you and the containers
- Good when you want to keep the mental model small
I run VEQO's compute on AWS through Dokploy and I would not fight anyone who preferred Coolify. What I care about is that the abstraction stays thin enough that I can drop to docker compose and docker logs when something is wrong, because eventually something is wrong and the UI will not tell you why.
The real thing to understand about both is what they are not. They are a control plane. They are not a guarantee. They do not make your database durable, they do not verify that your deploy did what you intended, and they introduce a new failure mode of their own: the control plane itself can be unreachable while the things it controls keep running.
That last sentence is the entire story of the outage I opened with, and I will come back to it.
A real AWS topology, and why it is shaped that way
VEQO is the most demanding thing I deploy, so it is the clearest example. It is a livestock traceability platform: biometric enrollment in the field, GPS-tracked transport, chain-of-custody records that have to survive a dispute a year later. Data residency is a hard requirement. The whole thing runs in Mumbai.
The topology is deliberately unexciting.
Edge
TLS, routing, rate limits
Route 53, ACM, load balancer with WAF
Web and API
Application surfaces
Docker services on AWS compute, managed through Dokploy
Realtime gateway
Server-sent events and reconnect
One TypeScript service, separately scaled
Worker
Outbox, jobs, exports, retention
Pinned TypeScript workers
Inference
Biometric computation
Private Python service, GPU only if measured
Database
Canonical state
RDS PostgreSQL 17, encrypted, private, Multi-AZ
Object storage
Evidence and exports
Private S3 buckets in the same region
Secrets
Credentials
AWS Secrets Manager, referenced not copied
Three origins are public: the app, the versioned API, and the realtime gateway. Device and webhook endpoints are authenticated, rate-limited, and monitored separately from human traffic, because they have a completely different abuse profile.
Notice what is split out and why. The realtime gateway is its own service because connection count scales on a different axis from request throughput, and because a deploy of the API should not drop every open stream. The worker is its own service because background work must not compete with request latency, and because you want to scale retries without scaling your web tier. The inference service is Python behind its own boundary because it has a different runtime, different scaling characteristics, and a failure mode the product must survive: when it is unavailable the system queues work or routes to human review rather than guessing.
That separation is not architectural vanity. Each boundary exists because the two things on either side of it fail differently.
The principle that makes self-hosting survivable
If I could pass on one rule from running this infrastructure, it would be this: compute must be replaceable.
Concretely, that means four commitments.
- Canonical state lives in the managed database, not in SQLite on a container volume and not on an instance disk.
- Bytes live in object storage, not in container volumes. Uploaded evidence, generated receipts, export artifacts, all of it.
- Images, configuration, migrations, runbooks, and infrastructure manifests live in version control.
- You have actually proven that a destroyed web, API, realtime, worker, or inference container can be replaced and reattached to the same database and the same buckets.
Point four is the one people skip. It is easy to believe your compute is stateless. It is a different thing to have deleted a service and watched a fresh one come up healthy against live state.
The reason this matters more when you self-host is that a managed platform enforces statelessness for you. It gives you an ephemeral filesystem and you design around it. On your own server, writing to local disk works beautifully right up until you need to move, scale, or rebuild the host, at which point you discover which of your features quietly depended on a directory.
Uploads on disk
Problem: A file upload endpoint writes to a container path because it was faster to build.
Better move: Presigned uploads straight to object storage, with the database holding metadata and authorization.
Sessions in memory
Problem: Login works perfectly until a second replica exists.
Better move: Sessions in the database or a shared store from the first day, even at one replica.
Cron on the box
Problem: A scheduled job lives in a crontab nobody put in version control.
Better move: Scheduled work as a defined service or a job the repository declares.
Generated assets kept
Problem: PDFs and exports written locally and served from there.
Better move: Write to object storage, serve through short-lived signed URLs.
SQLite for one small thing
Problem: A rate limiter or a queue that seemed too small to need Postgres.
Better move: Use the database you already run, or a shared Redis-compatible store.
Self-hosting Supabase: what you actually take on
Supabase is a good product and I have shipped on the managed version, including DealGPT, where it handles auth and data, and the Mojo-360 platform. The question here is what changes when you run it yourself.
What you get is real: Postgres with the extensions you want, an auth service, storage, realtime, auto-generated APIs, and the studio, all inside your own network and your own region. For a residency requirement or a cost curve that has stopped making sense, that is a genuine answer.
What you inherit is a distributed system with more moving parts than most teams expect. The database is one container. Auth is another. Storage, the API gateway, realtime, the metadata layer, and the studio are more. They are version-coupled, so upgrading is not one command, it is a coordinated set with its own release notes to read. Backups are entirely yours, and not just of Postgres: storage objects and auth configuration have to be covered too, or a restore gives you a database full of rows pointing at files that no longer exist.
The honest test I apply before self-hosting it:
- You need the whole surface: auth, storage, realtime, the studio
- Residency or compliance rules out the managed region
- Your team already operates multi-container systems confidently
- You have somewhere to practise upgrades before production
- You mainly need Postgres and a login flow
- Your auth needs are an organization model with roles
- Nobody on the team wants to own an upgrade path
- You would be running six containers to use two
That right-hand column is where most projects land, and it is not a compromise. VEQO does exactly this: RDS PostgreSQL as the system of record, Drizzle as the typed data layer, an auth library with our own organization and permission model on top, private S3 for bytes. Fewer components, each one boring, each one replaceable. The pieces Supabase bundles are all things you can assemble, and assembling them means you upgrade them independently.
If you do self-host it, treat the object storage layer as the sharp edge. Database restores are a solved problem with well-understood tooling. Restoring a database and a bucket to a consistent point in time together is the part that needs a drill.
Self-hosting Convex: a worked example
BidMorning runs on self-hosted Convex, and it is a good case study because the shape is small enough to see all of.
The product is an email-first tender alert service for Indian contractors. Convex holds the data and the backend functions. A Hono service on Node handles external webhooks and scheduled work. A React frontend with TanStack Router serves the customer archive and the admin console. Caddy terminates TLS in front of all of it.
The production layout is three subdomains on one Docker Compose stack:
The apex domain
Marketing and admin web
Static build served by nginx
api subdomain
Webhooks and scheduler
Hono on Node, timezone-pinned to Asia/Kolkata
convex subdomain
Self-hosted Convex backend
Backend and dashboard containers
Standing it up is a defined sequence, and writing it down as a runbook rather than remembering it is the difference between a repeatable deploy and an adventure. Bring the stack up once. Generate an admin key by executing the provided script inside the backend container. Put that key in the production environment file. Generate the JWT signing keys the auth layer needs and set both the private key and the JWKS on the Convex deployment. Set a shared service secret with the same value on both the API and the Convex deployment. Then deploy the functions.
Two things about that sequence bite people, and both bit me.
Convex deployment environment variables are separate from your host environment variables. The functions run inside Convex, not inside your API container. Setting the email provider key on the API host does nothing for a function that sends an email. I have a runbook line that exists purely because customer sign-in codes and enrollment invitations were throwing for exactly this reason: the variables were set, just in the wrong place.
Deploying functions is a distinct step from deploying containers. On managed Convex the deploy is one action. Self-hosted, your container platform rebuilds images from a Git push and your functions are pushed by a separate command against the self-hosted URL with the admin key. Those two paths can succeed and fail independently.
Which brings me to the failure.
The deploy that lied for a month
Here is what happened, because the specifics are more useful than the moral.
A merge landed on the main branch. The deploy workflow's first step was to push Convex functions to the self-hosted backend. To do that it had to reach the Dokploy control plane, and the control plane timed out. It retried. It timed out four times and the step failed.
The workflow stopped. So far this is a normal failed deploy, and a normal failed deploy is fine.
What made it an incident is that Dokploy's own push hook was also listening to that repository. Independently of the workflow, it saw the commit and rebuilt the API and the web containers. Those builds succeeded. New frontend, new API, both live.
The Convex functions never moved.
So production was serving current frontend code and current API code against backend functions that were a month behind them. Health checks passed, because every health check answered the question "is this process listening" and every process was listening. Uptime monitoring was green. The containers were the newest images. Nothing in any dashboard was wrong, because nothing any dashboard measured was wrong.
It ran like that for a month. The tell, when we finally looked, was a single query: call the backend for a function that the undeployed release was supposed to introduce, and get back "could not find public function."
Two deploy paths
A workflow and a platform push hook both watching the same repository, succeeding and failing independently.
Partial success
Nothing rolled back, because the parts that worked had no idea a sibling step had failed.
Liveness is not correctness
Every check asked whether a process was up. None asked which version was answering.
Compatible enough
The old functions still served most calls, so the product mostly worked and nobody escalated.
Alert on the wrong layer
Infrastructure monitoring was healthy because the infrastructure was healthy.
The fixes were three, and none of them were clever.
First, the pipeline now typechecks and runs tests before it touches production, rather than discovering problems mid-deploy. Cheap, obvious, and it was not there.
Second, and this is the important one: after the function deploy, the workflow queries the live deployment for a function that this release introduces. If the backend is still serving an older version, the query fails and the run fails with it. The deploy now proves it landed instead of assuming it did.
Third, the workflow reports an unreachable control plane as an unreachable control plane, rather than as a rejected deploy. Those are different problems and they need different humans.
The general lesson I would take to any stack: a deploy pipeline that does not verify its own outcome is a notification system, not a deployment system. Ask the running thing a question only the new version can answer. A version endpoint works. A commit hash in a health response works. A function the release introduces works. What does not work is trusting a green step.
Ship it dark, then open it
Part of what made that month survivable rather than catastrophic is a pattern I now use on anything with outbound side effects: deploy the code with the dangerous parts closed.
BidMorning's recovery sequence set three flags before the release went out: the processing pipeline off, the scheduler off, and digest sends off. The new code went to production, was verified, and only then were the flags opened one at a time in dependency order. Nothing sent an email to a paying subscriber until a human had confirmed the thing that generates those emails was actually the new version.
Deploy closed
New code live, scheduler and outbound sends disabled by flag.
Verify identity
Ask the running system for something only this version has.
Migrate
Run data migration behind an explicit preflight that can refuse.
Open processing
Enable the pipeline, watch it run with sends still closed.
Open sends
Enable outbound last, on a small cohort before the full list.
The migration step there deserves a note, because I like how it turned out. The migration endpoint refuses to run if a separate preflight check finds blocking conditions, and returns a conflict status rather than proceeding. Preflight reports a set of counts that all have to be zero: records still in flight under the old model, unclaimed records, unresolved profile errors, invalid channel states, unbackfilled items, and two integrity counts. You poll it until it reports ready, and if it never does, the cutover simply does not happen.
A migration that can refuse itself is worth building. The alternative is a migration that runs at 2am against a state you did not anticipate and leaves you reconciling by hand.
Environment variables are the most common outage
This is unglamorous and it is where I have lost the most hours across every project.
The pattern that finally worked is treating configuration as something you validate, not something you set. Zesty has a command that checks the production environment before a deploy is allowed to proceed, and it is the single highest-value piece of ops tooling in that repository. BidMorning has two: one validates the production environment file, another validates that the DNS, TLS, and public endpoints actually resolve and match what the configuration claims.
The rules I now apply everywhere:
Correctness
- Every deployable has a validator that fails loudly on missing or malformed values
- Canonical URLs are set consistently across every app that references another
- Auth trusted origins and cookie settings are explicit, not inferred from a request
- Values are checked for shape: real HTTPS URLs, not localhost that survived a copy
Safety
- Server secrets are never given a browser-readable prefix
- Secrets live in a manager and are referenced, not pasted between dashboards
- Secret values never appear in command history, build logs, or the repository
- Each environment has its own database, buckets, auth issuer, and provider keys
Two specific mistakes worth calling out because they are so easy and so damaging.
Prefixing a server secret as public. Frontend build tools inline anything with the public prefix into the bundle. A provider API key prefixed that way is not leaked when someone breaches you, it is published on your website. Grep your environment names for the prefix and read every single one.
Setting one URL and not the others. Auth callbacks, cross-origin rules, payment redirect validation, and notification links all derive from app URLs. Setting the API URL and forgetting the site URL produces an application that loads perfectly and cannot log anyone in. VEQO has three app URLs that must agree; Zesty has a site URL, a base URL, an API URL, and a set of public-prefixed equivalents. Getting one wrong is a working deploy with a broken product.
Migrations are the one-way door
Everything else in a deployment can be rolled back by redeploying the previous image. A schema change cannot, or at least not cheaply, and pretending otherwise is how teams end up restoring backups on a weekday evening.
The rules I will not bend on:
Never let application startup mutate production schema. Migrations run through a protected release job with an owner and an approval, not as a side effect of a container starting. Container orchestration will happily start three replicas at once, and three simultaneous migrations against one database is a very bad afternoon.
Backward-compatible first, always. Apply the migration in one release, deploy the code that depends on it in the next. Expand then contract: add the new column, write to both, backfill, switch reads, then drop the old column in a later release. Two boring releases beat one clever one.
Migrations need a direct connection. If you run a transaction pooler in front of your database, and you should at scale, your migration connection must bypass it. Pooled connections in transaction mode do not support the session-level operations that migrations need. This is why VEQO's configuration carries both a pooled URL for the application and a direct URL for migrations.
Backup first, and know that the backup restores. Taking a backup is not the safety measure. Having previously restored one is the safety measure.
Verify the migration landed. Check migration status as an explicit step, the same way you verify a deploy. Zesty's release checklist requires migration status evidence and rollback compatibility verification before a coordinated release across API, web, mobile, and worker, because those four can only be released together if the schema tolerates all of them at once.
Startup migrations
Problem: Three replicas start and race the same migration.
Better move: A protected release job, run once, with an owner.
Pooled migration connection
Problem: The migration hangs or errors against a transaction pooler.
Better move: A separate direct connection string used only for migrations.
Code and schema in one release
Problem: The rollout is half done and neither version works against the schema.
Better move: Expand and contract across two releases with a compatible middle state.
Untested backup
Problem: The restore fails or produces something unusable, discovered during an incident.
Better move: A restore drill into an isolated instance, with the result written down.
Destructive rollback
Problem: Rolling back deletes rows that finalized records depend on.
Better move: Forward corrections instead. Never rewrite finalized financial or custody history.
That last one is a rule VEQO states explicitly and I have adopted everywhere: data rollback must never delete or rewrite finalized custody or audit history. If a release produced bad records, you correct forward with compensating entries. You do not make the mistake disappear, because the record of the mistake is part of what makes the system trustworthy.
Health checks that check something
The month-long incident was a health check failure as much as a deploy failure, so it is worth being precise about what these should do.
Most health endpoints answer "am I running." That is liveness, and it is the least useful of the three signals you need.
Liveness
Is the process alive
Restart it if not. Cheap, shallow, no dependencies
Readiness
Can it serve traffic now
Checks dependencies. Gates the load balancer
Identity
Which version is answering
Commit hash, or a capability only this release has
Readiness is where dependency checks belong, and it should genuinely refuse. Zesty's order lifecycle worker exposes a readiness endpoint, and the rule is that production does not accept post-creation order traffic until that check passes. The same configuration principle applies to its events transport: if the Redis-compatible connection for order events is missing or unreachable, production readiness fails rather than starting in a degraded mode. That is a deliberate choice. Silently running without live order events would be worse than refusing to start, because staff would trust a kitchen display that had quietly stopped updating.
Identity is the one almost nobody implements and the one that would have caught my outage on day one. Expose the commit hash the build came from. Have the deploy pipeline read it back and compare. For a system where functions deploy separately from containers, query for a capability the new version introduces. It is a few lines and it converts "we think it deployed" into "it deployed."
What to actually measure
Observability advice usually arrives as a list of tools. The tools matter less than picking metrics that correspond to how your system actually fails.
For the systems I run, the signals that have earned their place:
The usual
- Error rate and latency at the API edge, split by route family
- Database connection pool pressure and transaction conflicts
- Certificate and secret expiry, alerted well before the date
- Host-level disk, because full disks take down databases
The ones that caught real problems
- Outbox lag: how far behind the worker is on pending effects
- Dead letters: effects that exhausted retries and need a human
- Queue and job backlog, with a separate alert for growth rate
- Backup age, alerting on a backup that is stale rather than failed
- Offline sync backlog from field clients, and sync failure counts
- Migration duration, which tells you when a release will hurt
Outbox lag and dead letters deserve emphasis. If you use the transactional outbox pattern, and for anything with money or stock you should, the outbox is the seam where your system's promises meet the outside world. A growing outbox means committed business facts that nobody has been told about. That is a customer-visible problem long before it is an infrastructure one, and no CPU graph will show it.
Backup age over backup success is the other one I would push. A backup job that fails loudly gets fixed. A backup job that succeeds while writing an empty file, or that silently stopped being scheduled, is the one that hurts. Alert on the age of the newest restorable artifact, not on the exit code of last night's run.
For tooling, VEQO uses OpenTelemetry with a Prometheus-compatible collector and SigNoz alongside cloud provider metrics; Zesty's plan is Grafana with Loki for logs. Either is fine. What matters is that request, actor, organization, job, and outbox event identifiers correlate across your services, so a support question about one customer's missing receipt can be traced from their click to the dead-lettered effect.
Backups you have restored
I will keep this short because the rule is short: a backup you have not restored is a belief, not a backup.
The layers I run for anything with real data:
- Provider-level automated backups with point-in-time recovery. This is your fast path for "someone dropped a table twenty minutes ago."
- Scheduled logical dumps, encrypted. This is your portability path. Point-in-time recovery ties you to the provider and the engine version; a logical dump can be restored into a different instance, a different provider, or a local container when you need to inspect something.
- Object storage versioning and lifecycle rules. Because the database is only half your state. Evidence files, exports, and generated documents need their own recovery story.
- Version control as the source of truth for everything else. Schema, migrations, service definitions, container configuration, operational manifests, runbooks.
- A protected inventory of the configuration and secrets a restore would need, because a perfect database restore into an environment with no credentials is not a recovery.
And then the drill, which is the part that converts the list into insurance. Restore a logical backup into an isolated instance. Validate constraints, extensions, indexes, and your critical queries against it. Restore the provider backup to a non-production target and point an application at it to confirm it reconnects cleanly. Replace compute while keeping database and object storage state, and verify it reattaches. Reapply configuration and secrets from their sources. Reconcile the outbox and any in-flight work. Then write down the recovery point and recovery time you actually achieved.
Those two numbers are a product decision, not an infrastructure one. How much data can this business afford to lose, and how long can it be down? Answer that with the people who carry the consequences, and then prove your setup meets it rather than inferring it from a provider's marketing page.
Rollback is a feature you build
If you cannot describe how you undo the release in two sentences, you do not have a release plan.
For containers this is straightforward and it is the main argument for pinned, immutable image tags: redeploy the previous image by digest. Which means never deploying a floating tag like latest to production, because "the previous image" has to be a thing you can name.
For schema, rollback is the expand-and-contract discipline above, plus a decision made in advance about whether the previous image is compatible with the current schema. Zesty's checklist requires verifying lifecycle command, revision, and outbox migration rollback compatibility before a coordinated release, precisely because the answer is not always yes and you want to know before you need it.
For behaviour, rollback is a feature flag. The riskier the change, the more it should ship behind one. Biometric model versions, a new processing pipeline, automated gate handling, outbound sends: all flag-gated, all reversible without a deploy.
And the piece people forget: a rollback drill. Take the previous known-good image, roll production or a production-like environment back to it, make the database compatibility decision explicitly, time it, and record who approved it and what smoke checks you ran afterwards. Zesty tracks that as a required evidence artifact before launch, and that is the right instinct. The first time you roll back should not be during an incident.
The go-live sequence I actually follow
Pulling it together, this is the shape of a production release for something with real users and real money moving.
Gate
Lint, strict typecheck, tests, migration validation against a fresh database, container build, secret scan.
Stage
Deploy to a production-like environment. Verify extensions, indexes, policies, health, outbox lag, connections.
Prepare
Confirm the checklist, the support window, backup status, and a named rollback owner.
Migrate
Backup, then backward-compatible schema changes through a protected job. Verify status.
Deploy
Pinned images for API, worker, realtime, and inference. Risky behaviour behind flags.
Prove
Query the live system for something only this version has. Fail the run if it answers wrong.
Watch
Error rate, outbox lag, queue backlog, provider webhooks, for a defined window.
Record
Version, commit, actor, approver, images, result, and post-deploy smoke evidence.
That last step is worth defending, because it looks like bureaucracy and is not. Zesty requires a deployment record for every production deploy capturing version, commit, actor, approver, images, result, and smoke evidence. The reason is simple: when something is wrong at 9pm, the first question is always "what changed and when." A deploy record answers it in ten seconds. Reconstructing it from container timestamps and chat history takes an hour you do not have.
The same instinct explains a pattern in that repository I have come to like a lot. Evidence artifacts are tracked as configured paths: storage policy, rate limiting, database security, secret scans, dependency scans, software bill of materials, container scans, penetration scan output, email deliverability, support coverage, deployment records, rollback drills, incident drills. Making evidence a first-class configured thing means "have we done this" has an answer that is a file, not an opinion.
Email deliverability is deployment work
A small section for something that is not usually filed under deployment and absolutely should be.
If your product sends email that matters, and BidMorning's entire product is a daily email, then sender configuration is production infrastructure. The pattern that works:
- Send from a dedicated subdomain, not your apex domain. BidMorning sends from a subdomain reserved for it; Zesty uses one for support mail. This isolates your transactional reputation from everything else that touches your main domain.
- Publish and verify sender authentication properly: the sender policy record, signing keys, and a policy record for handling failures, plus any return-path record the provider asks for.
- Scope the API key to that sending subdomain and make it send-only. It is a credential that will end up in an environment file on a server.
- Configure the delivery webhook and its signing secret, then confirm you are recording delivered, bounced, and complained events, along with suppression, idempotency, and retry behaviour.
- Test to an allowlist before a cohort send, and never run an old and a new generation path at the same time.
Capturing deliverability evidence, with the authentication records and real message headers, before launch is on Zesty's checklist for the same reason the other evidence is: the alternative is finding out from a customer that your receipts go to spam.
Environments, and the one nobody funds
Four environments, and the rule that they share nothing:
| Environment | What runs | Data | Purpose |
|---|---|---|---|
| Local | Everything, in Docker | Containerised database, local object storage, mocked providers | Feature work without production data |
| Development | Shared integration | Isolated database and buckets | Team integration and provider sandboxes |
| Staging | Production-like, same region | Dedicated everything, its own monitoring | Release, migration, recovery, and load rehearsal |
| Production | The real thing | Pinned, approved | Customers |
Different domains, databases, buckets, secrets, auth issuers, notification providers, and admin credentials. Never copy production data into development, and for anything sensitive that is not a guideline, it is a rule with legal weight.
Local deserves investment. Containerised Postgres with the same extensions production uses, local object storage that speaks the same protocol as your cloud bucket, mocked inference and mocked providers. If a developer cannot run the whole system on a laptop, they will test in staging, and staging becomes a queue.
Staging is the environment nobody wants to pay for and everybody needs. It is where a migration runs against realistic data volumes for the first time, where a recovery drill happens, where a load test tells you which index is missing. If staging does not match production in region, topology, and roughly in shape, it is a demo environment wearing a staging label.
What I would choose today, by situation
Marketing site or docs
Managed platform. There is no argument here. Static output on a CDN, zero operational cost.
Early SaaS, no revenue yet
Managed everything. Managed database, managed auth, managed hosting. Buy attention.
SaaS with a worker and real data
Self-hosted PaaS on cloud compute. Dokploy or Coolify, a managed database, object storage.
Residency or compliance constraint
Cloud primitives in the required region. Managed database, private object storage, a secrets manager.
Heavy background or ingestion work
Self-hosted, because usage-priced platforms punish workloads that are large and low-revenue.
Anything with a GPU or odd runtime
Your own compute, behind its own service boundary, so its failures stay contained.
The mixed answer is usually the right one, and I want to be honest that my own stacks are mixed rather than pure. VEQO's production plan is self-hosted Docker services on AWS Mumbai through Dokploy, with a managed database, managed object storage, and a managed secrets service underneath. Its supporting apps have a straightforward path to a managed platform for preview deployments. Purity is not a goal. Every component should sit at the cheapest tier that satisfies its actual constraints, and "cheapest" includes your hours.
Things I got wrong, plainly
Two deploy paths
Problem: A workflow and a platform push hook both deploying, succeeding independently.
Better move: One path. If the platform also auto-deploys, disable it or make the workflow the only trigger.
Health checks that only ping
Problem: A month of production serving stale backend functions behind green checks.
Better move: Add identity: query the running system for something only this version has.
Config set in the wrong place
Problem: Provider keys on the API host when the functions that need them run elsewhere.
Better move: Document which variables belong to which runtime, and validate each one in place.
Believing a passing backup job
Problem: Backup success measured by exit code rather than by a restore.
Better move: Drill the restore, alert on artifact age, and write down the achieved recovery point.
Floating image tags
Problem: No nameable previous version to roll back to.
Better move: Pinned, immutable tags or digests, and a rollback drill before you need one.
Staging that was not production-like
Problem: A migration that was fast on a small dataset and slow on a real one.
Better move: Same region, same topology, realistic volumes, or stop calling it staging.
A checklist you can steal
Infrastructure
- Canonical state is in a managed database, not on a container volume
- Bytes are in private object storage with short-lived signed access
- Secrets are in a manager and referenced, never pasted between dashboards
- Every environment has its own database, buckets, auth issuer, and keys
- A configuration validator runs and can block the deploy
Release
- One deploy path, with no second hook watching the same repository
- Tests and typecheck run before anything touches production
- Migrations run in a protected job, never at application startup
- Images are pinned, and the previous one can be named
- The pipeline proves the deploy landed by querying the live system
Operations
- Readiness checks dependencies and genuinely refuses traffic
- Outbox lag, dead letters, and backup age are alerted on
- A restore has been performed, and the recovery point is written down
- A rollback drill has been performed and timed
- Every deploy leaves a record: version, commit, actor, approver, result
- There is a runbook for the five failures you consider most likely
The uncomfortable summary
Self-hosting is not cheaper. It moves cost from a monthly invoice to your calendar, and the exchange rate is worse than it looks on the day you decide. What it buys is control: over residency, over cost shape at scale, over which components you can run at all. Those are real things and sometimes they are decisive.
What it demands in return is discipline that a managed platform was quietly providing for you. Statelessness it used to enforce. Deploy verification it used to do. Backups it used to take. Certificate renewal, log retention, dependency patching, restore paths: all of that was in the invoice, and now it is in your week.
The failure I keep coming back to is instructive precisely because none of it was exotic. No breach, no data loss, no hardware fault. A control plane timed out, a second deploy path kept going, and every check I had was asking the wrong question. The system reported healthy for a month while serving code that did not match itself.
So if you take one thing: make your deployment prove itself. Ask the running system a question only the new version can answer, and fail the deploy when it answers wrong. Everything else in this article is worth doing. That one is worth doing this week.
For how the backend those deploys carry stays correct under concurrent writes, read the order lifecycle behind Zesty. For the delivery process this sits inside, see requirements to production.
Own your infrastructure when the constraints justify it, and never own it without evidence.