22 February 2026·8 min read

On-prem-ready by default: shipping the same product to AWS, a VPS, and a school server room

Every SourceForge product is deployable to any Docker host without a code change. The principle, the practice, and the small details that make it actually work.

SourceForge Engineering
Product engineering practice

Every product we ship is deployable on any Docker host without a code change. AWS, a Hostinger VPS, a customer's air-gapped data centre, a school server room running on a single GPU box. This is not marketing — it's an engineering principle that costs real money to maintain, and pays off in ways that have surprised us repeatedly.

The principle

Application code in apps/ and packages/ is host-agnostic. Host-specific configuration lives in infra/. Crossing the boundary requires explicit justification at code review.

In practice this means:

  • No references to AWS-specific services (S3, Rekognition, SES) directly in business logic — they go through a thin abstraction we can swap
  • No references to Hostinger-specific paths, environment variables or runtime quirks
  • No references to Cloudflare-specific bindings in business logic
  • All file storage goes through a "Storage" abstraction with adapters for S3, MinIO, the local filesystem
  • All email goes through a "Mailer" abstraction with adapters for SES, Resend, SMTP
  • All authentication state lives in Postgres, never in a vendor-specific session store

The lint rule that enforces this is one of the most useful pieces of CI we've shipped. It's roughly 40 lines and catches drift early.

Why this is worth the cost

The principle costs us. Every new integration starts with "where will this run on a school server room with no internet?" That question slows us down by maybe 10% on greenfield code.

The cost has been paid back four times that we can point to:

  1. A jewellery customer in Saudi Arabia mandated on-prem hosting due to data residency. We deployed our Gold ERP on their Hetzner VM in two days, not two months.
  2. A government skill-mission customer running 4000 training centres needed an air-gapped deployment of Language Lab. We shipped them a single Docker Compose file. Total integration effort: one week.
  3. AWS doubled S3 transfer pricing in 2025 for our region. We migrated three customers to MinIO over a weekend. The "vendor lock-in tax" was hours of work, not weeks.
  4. A school district running CognitiveIQ wanted everything to stay on their LAN — no cloud at all. The same product runs on their one-rack server room as runs in our AWS production.

In each case, "on-prem ready" was not a separate feature; it was the natural consequence of the principle. We didn't have to "add" on-prem support.

How the abstractions look

A typical abstraction — Storage:

interface StorageAdapter { putObject(key, body): Promise<void>; getObject(key): Promise<Body>; getSignedUrl(key, ttl): Promise<string>; }

The interface has three implementations: S3 (production AWS), MinIO (on-prem), Filesystem (dev). The choice is made at boot time from an environment variable. Business logic only knows about the interface.

The same pattern applies to:

  • Mailer (SES / Resend / SMTP / Logger-for-dev)
  • ImageAnalysis (Rekognition / a local ML model / a no-op for offline)
  • VectorStore (pgvector / Supabase / a local FAISS index)
  • BackgroundQueue (BullMQ on Redis is the only impl, but the interface is there so we could swap)

We do not abstract everything. The database is Postgres, always. The web runtime is Node, always. The line is at "things that have meaningful regional / customer / cost variation".

Docker Compose as the deployment unit

Every product ships with a compose.production.yml that boots the entire system on one host. Postgres, Redis, the app, the workers, the cron, the backup sidecar. A customer can run docker compose up and have the product running.

For larger deployments we provide split-compose files — compose.app.yml for the app tier, compose.data.yml for the database tier — so customers can put the database on dedicated hardware. The application config is the same across both.

The Compose file is checked into infra/, not the application repo. The application repo doesn't know it's being deployed by Compose; the application is just a Docker image with a documented set of environment variables.

The painful part: migrations

The hardest part of "deployable anywhere" is database migrations. We ship migrations as one-shot containers that run before the app container starts. The container is idempotent by construction: every migration uses CREATE TABLE IF NOT EXISTS, every ALTER includes IF NOT EXISTS for new columns, every INDEX is CREATE IF NOT EXISTS.

The discipline is non-negotiable. Once we shipped a migration that wasn't idempotent and a customer re-ran the migration container during a recovery. Half the migrations re-ran cleanly; one failed because the migration assumed a column it had already created didn't exist. We lost two hours of customer time. We've never shipped a non-idempotent migration since.

The backup story

Every Compose file includes a backup sidecar. Postgres dumps run nightly, retain 14 days locally, and (if configured) push to S3 / MinIO / a customer-provided destination. The Compose file is the same across cloud and on-prem; what differs is only the backup destination URL.

A customer running fully on-prem with no off-site backup gets a stern letter from us and a recommendation to set up at least an off-site rsync. We can't make them; we can document the risk.

What we won't abstract

We don't abstract the operating system. Linux on x86_64 or ARM64, period. We've considered shipping Windows-based images for a few BC-adjacent products; we've not done it. The maintenance cost is much higher than the customer-acquisition benefit.

We don't abstract the application runtime. Node 24 LTS. We don't ship Bun, Deno, or anything else. Single runtime, single set of operational behaviours.

We don't abstract observability. Pino logs to stdout; whoever runs the deployment is responsible for shipping logs to wherever they want. We won't write our own collector for every customer's log destination.

The discipline scales

This pattern is not actually about cloud portability. It's about keeping concerns separated. Once you've internalised "host-specific things go in infra/", you start to see other "specific things should not live in app code" rules:

  • Brand-specific things go in config/, not app code
  • Customer-specific things go in workspace config, not app code
  • Country-specific things (tax, dates, currency) go in locale config

The discipline compounds. Eventually you have an application codebase that can be reasoned about without knowing which customer, country, or cloud is running it. That, more than anything, is what makes a small team able to ship a portfolio of nine products to eleven countries.

Written by
SourceForge EngineeringProduct engineering practice

Published 22 February 2026 by SourceForge Software Services Pvt Ltd. Replies, corrections and follow-up questions: info@sourceforge.in.

Have a project that touches what you just read?

The blog exists because we'd rather show our thinking than pitch it. If something here resonated, let's talk about how it applies to your situation.

WhatsAppCall us