Tearing a Multi-Tenant SaaS Into an Air-Gapped Box
Shipping a cloud SaaS as one on-prem appliance is not a deployment change. It means removing tenants, credits, managed auth, and every cloud dependency, in the right places.
A customer wants your multi-tenant media SaaS as a single box on their own network, no internet, no per-use billing, unlimited processing for their staff. That sounds like a packaging job. It is not. The product assumes tenants, credits, managed sign-in, and a cloud control plane at every layer, and each of those assumptions has to be pulled out at the exact place it lives. Miss one and the appliance either leaks a cloud call or fails the "unlimited" requirement it was sold on.
The trap: hiding the UI is not removing the limit
The requirement was unlimited processing. The first instinct is to hide the upgrade page and the credit meter. That is exactly wrong. The credit check does not live in one place. It runs in three:
- The frontend hides actions when the balance is zero.
- The API rejects a request that would exceed the balance.
- The worker decrements the ledger when a job completes.
Hide only the UI and the API still returns "insufficient credits" the moment a hidden counter hits zero. The job still fails deep in the worker. To actually deliver "unlimited" you remove the check in all three layers at once: no UI gate, no API rejection, and the ledger decrement turned into a no-op. This is the one place where a partial change looks done in a demo and breaks in week two.
Every cloud dependency needs an on-prem replacement
Air-gapped means the box makes no call you did not explicitly allow. The SaaS reached out to a cloud service at startup and on nearly every request. Each of those became a local replacement:
The concrete swaps were:
- Config came from a cloud parameter store at boot. Replaced with a seeded local
.envfile the installer writes. - Signed media URLs were minted by a managed edge worker. Replaced with an
nginxreverse proxy that verifies an HMAC on the path. - Sign-in was cloud single sign-on. Replaced with local id and password accounts an admin pre-creates, no self-serve signup.
- Object storage was a cloud bucket. Replaced with a local S3-compatible server, reusing the same client with a different endpoint.
Outbound calls got an allowlist of exactly two: the translation API and the text-to-speech vendor. Everything else is a local process on the box.
The environment gate that refuses to start
The service had a startup check that refused to run outside its known cloud environments, a guard against someone booting production config by accident. On an air-gapped box that guard fires on every boot. The fix was a new environment mode, "on-prem", that the config validator accepts, so the same binary starts against local infrastructure without the cloud checks it can never satisfy. This is cleaner than deleting the guard, because the guard still protects the cloud builds.
Read the code before you trust your own architecture diagram
Part of the migration was ripping a cloud config client out of every service. One worker was on the list because "it reads config from the cloud at runtime". It does not. Reading the code showed it had always read a plain local file, and the cloud parameter store only ever appeared in its deploy script, never in the running process. That worker needed zero code changes, only a different seed file.
The lesson repeats on every extraction like this: your mental model of what depends on the cloud is a guess until you grep for the actual client calls. The dependency you dreaded can turn out to be a deploy-time convenience, and the one you forgot is buried in a request handler. Confirm each one against the code, not the diagram in your head.
What "air-gapped" actually costs
Turning a SaaS into an appliance is mostly subtraction, done carefully:
- Remove per-tenant and per-use limits in the frontend, the API, and the worker together, or the "unlimited" promise breaks where the demo cannot see it.
- Give every cloud dependency a named local replacement, and put a hard allowlist on outbound calls so a forgotten one fails loudly instead of phoning home.
- Add an environment mode rather than deleting the guards that protect your cloud builds.
- Verify each dependency against the source, because the risky-looking ones are often deploy-time only and the real ones hide in request paths.
Related posts
Sizing an On-Prem GPU from Weeks of Resident VRAM Data
A spec sheet tells you the card's memory, not what your container actually holds at rest. Here is how weeks of resident VRAM readings sized a single on-prem GPU.
Cache Invalidation After Deploy: What to Purge, What Not To
Deploy every day without stale edges or origin overload. Sort assets into immutable hash keys you never purge and mutable HTML you purge by path.
Cloud Run Service vs Job: One Go Binary, Two Entry Points
A Cloud Run Service and Job can share one Go image and one build. Here is how a single binary splits into a serve mode and batch jobs, and when not to.