Nazif Ishrak
← Back to blog

The $0.00 Monolith: Why I'm Running NestJS and Background Crons in a Single Lambda

TL;DR: Stop paying the "idle tax" for your startup or side projects. By wrapping a NestJS monolith in a single AWS Lambda and hijacking EventBridge for background tasks, I built a production-ready backend that scales to infinity but costs exactly $0.00 while idling.


Assumed Audience: This post assumes familiarity with Node.js, NestJS, and basic AWS concepts. If you're tired of paying $20/month for a VPS that sits at 1% CPU usage, this is for you.


The Problem: The "Idle Tax" of Modern Web Dev

Every "Getting Started" guide tells you to buy a VPS. You pay $15, $20, or $30 a month for a box that spends 99% of its time idle. While this may seem like a low-cost option for some, scaling the app requires larger servers and, consequently, ever-increasing costs. With AWS's generous serverless free tier, taking advantage of these benefits would be amazing—if only development were as straightforward as building traditional serverful apps.

To put a concrete number on it: a modest side project might run a DigitalOcean Droplet ($12/mo), a managed Postgres instance ($15/mo), and a Redis cache ($10/mo). That's $444/year before you've earned a single dollar. AWS Lambda, by contrast, includes 1 million free invocations and 400,000 GB-seconds of compute every month, forever. For a side project receiving a few thousand requests a day, you will not pay a cent.

I wanted the enterprise-grade developer experience of NestJS (Dependency Injection, Type Safety, Modules), but I wanted the bill of a ghost. The solution? A "Serverless Monolith" that only exists when someone actually uses it.

The "Wrong" Architecture: One Function to Rule Them All

Traditional serverless advice says: "Break your app into 50 functions!"

I disagree — for side projects and small teams. That approach leads to deployment hell, shared logic nightmares, and a cold start penalty for every single endpoint.

When microservices actually make sense

To be fair: microservice architecture is a genuinely powerful pattern. The reasons it exists are valid and well-earned:

  • Independent scaling: Your image-processing service hammers CPU while your user-auth service barely breathes. Microservices let you scale only the bottleneck, not everything at once.
  • Team autonomy: At companies like Netflix or Uber, dozens of teams work in parallel on different domains. Each team can own, deploy, and version their service independently without coordinating a monolithic release train.
  • Fault isolation: A crash in your recommendation engine shouldn't take down your checkout flow. Service boundaries create natural blast radiuses that contain failures.
  • Polyglot stacks: Maybe your ML pipeline is Python, your real-time WebSocket server is Go, and your CRUD API is Node. Microservices let each component live in its natural environment.
  • Regulatory or compliance boundaries: Some domains (payments, healthcare records) require strict data isolation that is naturally enforced by a hard service boundary.

These are all legitimate reasons. But notice what they share: they solve problems of organizational scale and traffic complexity. If you are one or two developers building a side project, you have no teams to decouple, no isolated traffic spikes to right-size, and no regulatory moats to enforce. The overhead of 50 Lambda functions — each requiring its own IAM role, deployment config, log group, and shared-library versioning strategy — will slow you down far more than any architectural benefit will help you. Reach for microservices when you have the specific pain they were designed to solve.

Instead, I took my entire NestJS app, shoved it into a single Lambda function, and then tricked that function into acting as its own background worker.

Step 1: NestJS in a Bottle

We use @codegenie/serverless-express to bridge the gap between Lambda's event-based execution and NestJS's persistent nature. The app boots once during a "cold start," then the instance is cached for subsequent requests.

// src/lambda.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import serverlessExpress from '@codegenie/serverless-express';
import { Handler } from 'aws-lambda';

let server: Handler;

async function bootstrap(): Promise<Handler> {
  const app = await NestFactory.create(AppModule);
  app.enableCors();
  await app.init(); // Initialize the DI container

  const expressApp = app.getHttpAdapter().getInstance();
  return serverlessExpress({ app: expressApp });
}

export const handler: Handler = async (event, context, callback) => {
  // Re-use the cached app instance to avoid 2-3s boot times
  server = server ?? (await bootstrap());
  return server(event, context, callback);
};

Step 2: Hijacking EventBridge for Background Jobs

Most apps need background tasks — cleaning up expired sessions, sending scheduled emails, updating trip statuses. Traditionally, this requires a persistent Cron daemon or a dedicated message queue service, both of which cost money to keep running.

Instead, I set up a 5-minute EventBridge rule pointing to the same Lambda. I then added a branch at the top of the main handler to detect whether the invocation came from an AWS scheduled event rather than an API request.

// Inside src/lambda.ts handler
if (event.source === 'aws.events') {
  server = server ?? (await bootstrap());

  // Reach into the NestJS container and pull the Task service manually
  const tasksService = cachedApp.get(TasksService);
  await tasksService.handleTripLifecycle();

  return 'Lifecycle task completed';
}

This is the "technical heresy": the same code that serves your user's login request is also managing your database lifecycle 5 minutes later. There is no separate worker process, no queue to monitor, and no extra bill to pay. The Lambda either wakes up because a user hit an endpoint, or because EventBridge nudged it on a schedule — and it handles both cases from within the same NestJS application context.

Step 3: Killing the "Cold Start" Beast

A heavy NestJS + Prisma bundle can take 5+ seconds to boot from scratch. To keep the app snappy — and well within the free tier's memory and duration limits — the build pipeline needs to be lean:

  1. Esbuild Bundling: Everything is compiled and minified into a single .js file. No node_modules folder shipped to Lambda; only the code that actually gets called is included.
  2. Prisma Wasm Engine: We use engineType = "library" and target Wasm to avoid shipping heavy platform-specific native binaries, which can balloon a deployment package by tens of megabytes.
  3. Dependency Tree-Shaking: We explicitly mark large optional dependencies (e.g. swagger-ui, development-only decorators) as external so they are excluded from the bundle entirely.

The cumulative effect brings cold start time from ~5 seconds down to under 1.5 seconds for most workloads — fast enough that most users won't notice.

The Results: $0.00 and Zero Maintenance

| Metric | VPS (Traditional) | Serverless Monolith | |---|---|---| | Monthly Cost | $5.00 – $20.00 | $0.00 (under 1M reqs) | | Idle Usage | 100% Billable | 0% Billable | | Scaling | Manual / Load Balancer | Automatic (AWS handles it) | | Background Jobs | Requires separate process | Integrated via EventBridge |

Trade-offs and Limitations

Nothing is perfect. Here is why you might not want to do this:

  • Cold Starts: The first user after ~15 minutes of inactivity will experience a ~1–2 second delay while the container boots. For Wandrers, this is an acceptable trade-off for the cost savings. If sub-100ms latency is a hard requirement, consider Lambda's Provisioned Concurrency (though this does introduce a small cost).
  • Memory Limits: Lambda has a hard ceiling of 10 GB RAM and a 15-minute execution timeout. If you're doing heavy video transcoding, large image processing, or long-running data pipelines, a monolith will eventually hit the wall and you'll need to offload those workloads.
  • Database Connections: Serverless functions can easily overwhelm a traditional Postgres instance by opening hundreds of short-lived connections simultaneously. Use a connection pooler — Supabase and Prisma Accelerate both handle this elegantly — to avoid exhausting your database's connection pool.
  • Not for high-traffic production apps: Once you're serving millions of requests a day with real revenue on the line, the economics shift. At that scale, a dedicated container or ECS setup often becomes cheaper and more predictable than Lambda. This pattern is optimized for the "zero to traction" phase.

Conclusion

Building "wrong" is often the most efficient way to build as a solo founder. By treating Lambda as a "sleeping server" and EventBridge as a "ghost heartbeat," I've built a system that is robust, enterprise-typed, and completely free to run.

Stop paying for idle time. Your server shouldn't be a house you live in; it should be a tent you only pitch when you need to sleep.


Further Reading