DEV Community

Cover image for You already wrote your infrastructure. It's called your NestJS/ExpressJS App!
Feras Allaou
Feras Allaou

Posted on

You already wrote your infrastructure. It's called your NestJS/ExpressJS App!

Adding a cron job should be one file

Here's a thing that happens on every NestJS project I've worked on.

You need a job that refreshes a cache every five minutes. In your app, that's one function. Twelve lines, tops.

Then you go make it real:

  • a Rule in your CDK stack (or a aws_cloudwatch_event_rule in Terraform)
  • a second Lambda, because the API function shouldn't own it
  • a new bundle entry so the job actually gets built
  • an IAM policy, because it reads from the same table
  • an env var, added in two places, because the infra repo has its own idea of config

Five files, none of which are the twelve lines you actually wrote. And every one of them is a restatement of a fact your code already knows.

That's the part that always bothered me. The infrastructure isn't new information. It's a translation of something already sitting in your source tree and you're the translator, forever, by hand.

The two options, and what each one costs

Broadly you pick one of these.

A platform. Push, get a URL. Genuinely great until you need a queue, or your compliance person asks where the data lives, or you look at the bill and realize you're paying a markup to run in someone else's account. You don't own the infrastructure, so you can't inspect it, can't extend it, and can't leave with it.

Worth calling out: some platforms in the Node space solve "deploy to your own cloud" by asking you to create a long-lived IAM user with near-admin permissions and hand it over. That's your account, technically. It's also a set of keys you don't control, sitting in someone else's database, with rights to read your secrets.

Infrastructure as code. CDK, Terraform, Pulumi, SAM. You own everything, everything is inspectable, nothing is magic. The cost is that you now maintain a second codebase whose entire job is to describe the first one and the two drift. Not dramatically. Quietly. A route gets deleted and its rule stays. A queue gets renamed in the app and the consumer keeps polling the old one. An env var is added to .env and not to the stack, and you find out in production.

Drift isn't a discipline problem. It's the structural consequence of writing the same fact in two places and hoping.

A third option: don't write it twice

What if the deploy tool just read your app?

Your routes are already an HTTP API. A function you scheduled is already a scheduled job. A method you marked as consuming a queue is already a queue consumer. Those aren't hints about infrastructure — they are the infrastructure, expressed in the language you were already writing in.

That's the idea behind laranja an open-source CLI I've been building. It reads your Express or NestJS source, works out what infrastructure it implies, and provisions it in your own AWS or Azure account using your own local credentials.

No YAML. No second codebase. No CDK or Bicep to learn or install.

What it looks like

Mark your app. That's the whole HTTP surface:

// src/app.ts
import express from "express";
import { http } from "@alzulejos/laranja-decorators";

const app = express();
app.get("/health", (_req, res) => res.json({ ok: true }));
app.get("/users/:id", (req, res) => res.json({ id: req.params.id }));

export default http(app); // ← the marker laranja looks for
Enter fullscreen mode Exit fullscreen mode

The cron job from the intro, in full — no accompanying stack file:

// src/jobs.ts
import { cron, rate } from "@alzulejos/laranja-decorators";

export async function refreshCache() {
  console.log("refreshing…");
}
cron({ schedule: rate(5, "minutes") }, refreshCache);
Enter fullscreen mode Exit fullscreen mode

On NestJS you keep your DI, your modules, your constructor injection decorators sit on real providers:

// src/event/queue.service.ts
import { Injectable } from "@nestjs/common";
import { Queue } from "@alzulejos/laranja-decorators";

@Injectable()
export class QueueService {
  constructor(private readonly mailer: Mailer) {} // real DI, untouched

  @Queue({ name: "emails", batchSize: 10 })
  async sendEmails(body: EmailJob) {
    await this.mailer.send(body);
  }
}
Enter fullscreen mode Exit fullscreen mode

Then:

$ laranja deploy

🍊 laranja · deploy my-api → eu-central-1
  🔑  account   123456789012
  📦  build     7 routes · 2 crons · 1 queue → 4 λ
  ✓ λ my-api-app-prod
  ✓ λ my-api-refreshCache-prod
  ✓ 📨 emails
  ✅ deployed in 38s

  🌐  http   https://abc123.lambda-url.eu-central-1.on.aws/
  ✨ live
Enter fullscreen mode Exit fullscreen mode

Real Lambdas, a real Function URL, a real SQS queue with a real event source mapping — in your account, visible in your console, named ‹app›-‹fn›-‹stage› with no random suffixes. On Azure the same code becomes a Function App and a Storage Queue; you switch with one provider field in the config, not a rewrite.

Things I'd want to know before trying it

It reads your code, it never runs it. Discovery is static analysis, so plan is always safe — nothing of yours executes just to work out what would be deployed. The tradeoff is that a few things have to be literal enough to see: rate(5, "minutes") rather than a schedule computed at runtime.

Your source stays on your machine. laranja scans and bundles locally, then sends only a description of the infrastructure the internal model plus asset hashes — to the server, which returns a CloudFormation or ARM template. Your code and your bundles don't cross the wire. Neither do your cloud credentials: the template is applied locally, by you, with whatever's on your standard credential chain (aws configure, SSO, az login). There's no IAM user to hand over.

There's an exit. laranja eject gives you a real, owned infrastructure project. If the abstraction stops fitting you leave with working code rather than starting over. I'd rather you be able to walk away than be stuck.

It's an early MVP. Express and NestJS, AWS and Azure, HTTP + crons + queues + env vars + stages. That's the honest scope today. APIs may still change. Internally the app is reduced to a framework- and provider-neutral model, which is how more of both are meant to land without changing how you write code but "meant to" is doing real work in that sentence, and I'd rather say so than imply the roadmap is already shipped.

The actual claim

I'm not claiming IaC is bad. I've shipped a lot of CDK and I'd do it again for anything genuinely complex.

The claim is narrower: for the common case — an API, some jobs, a queue — the infrastructure is fully determined by the app, and writing it out by hand is duplicated effort that decays into drift. Deriving it removes an entire category of bug, because there's no second copy left to disagree with the first.

Docs are at laranja.io/docs, and the client packages (CLI, decorators, and the libraries around them) are Apache-2.0 on GitHub.

If you've solved this differently — or you think deriving infra from code is a bad idea and can say why — I genuinely want to hear it. That argument is more useful to me right now than stars.

Top comments (0)