Skip to content

Deploying a Full Stack SaaS

Aman Kumar Singh8 min read
Part 34 of 40From the Full Stack SaaS Masterclass series
Deploying a Full Stack SaaS — article by Aman Kumar Singh

The previous article in this series covered feature flags: how to decouple deploying code from releasing it, so a bad idea can be turned off without a rollback. That only pays off if deploying itself is a boring, repeatable event rather than a stressful one. This article is about making it boring.

This is part of the Full Stack SaaS Masterclass, and it sits at a point where the temptation to overbuild is strongest. Deployment invites that kind of overreach. Engineers reach for Kubernetes and blue-green everything before they have the traffic or the team that justifies any of it. I want to walk through the choices in the order I'd actually make them, honest about which ones are earned early and which aren't.

None of what follows assumes a platform team. It assumes a small team shipping a real product, choosing infrastructure that won't fight them at 2am.

Picking a deployment target without overbuilding

There are roughly three tiers of deployment target, and the right one depends on where you actually are, not where you expect to be in two years.

A single VM (or a couple) running Docker Compose is the honest starting point for most SaaS products. It's the same compose discipline from the local Docker setup article, pointed at a real box instead of your laptop, fronted by Caddy or Nginx for TLS. It's unglamorous. It's also completely capable of running a real product with real paying customers. The failure mode people worry about, a VM going down and taking the app with it, is a real risk, but one you can measure against your actual uptime requirements instead of assuming away.

A managed container platform, ECS Fargate on AWS, Cloud Run on GCP, or a PaaS like Render or Fly.io, is the next tier. You give up some control over the machine in exchange for the platform handling restarts, health checks, and scaling. This is where I'd point most teams once more than one engineer touches infrastructure, since the operational surface area stays small enough that nobody needs to become a full-time platform engineer.

Kubernetes is the tier I'd defer the longest. It solves real problems, multi-service orchestration and fine-grained scaling across a large team, but demands operational maturity (RBAC, network policies, ingress, cluster upgrades) most SaaS products never need. Reaching for it because "serious companies use it" is exactly the complexity this series argues against taking on early. Once you have several services and teams with genuinely different scaling needs, the calculus changes. Until then, it's a cost with no offsetting benefit.

The tradeoff repeats throughout this series: more control costs more operational effort, and that effort should go only where a concrete problem justifies it.

The build and release pipeline

Whatever the target, the pipeline shape is the same: build once, test the build, then promote the same artifact through environments. Building separately for staging and production is a subtle trap: what you test in staging and what you ship to production end up being two different builds.

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - run: npm run lint

      - run: npm run test

      - name: Build API image
        run: |
          docker build \
            --target production \
            -t ${{ secrets.ECR_REGISTRY }}/api:${{ github.sha }} \
            -f apps/api/Dockerfile .

      - name: Push image
        run: |
          aws ecr get-login-password --region us-east-1 | \
            docker login --username AWS --password-stdin ${{ secrets.ECR_REGISTRY }}
          docker push ${{ secrets.ECR_REGISTRY }}/api:${{ github.sha }}

  deploy-production:
    needs: build-and-test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Update ECS service
        run: |
          aws ecs update-service \
            --cluster saas-production \
            --service api \
            --force-new-deployment \
            --task-definition saas-api:${{ github.sha }}

Two decisions here matter more than they look. Tests run before the image is even built, so a broken build never gets tagged with a commit SHA and never becomes a deploy candidate. And the image is tagged with the git SHA rather than latest, which is what makes rollback a one-line command instead of an archaeology project: you always know exactly which commit is running, and reverting just means pointing the service at a previous SHA.

The environment: production block does real work too. GitHub Environments let you require a manual approval before that job runs, a cheap, low-ceremony gate that catches the "I meant to push to a feature branch" class of mistake before it reaches real users.

Zero-downtime deploys and health checks

A deploy that drops requests, even for a few seconds, is a deploy your users notice. Avoiding that has little to do with clever infrastructure. It comes down to the platform knowing when a new instance is actually ready before it stops sending traffic to the old one.

That starts with an honest health check endpoint, not just a 200 OK that ignores whether the app can actually do its job:

// apps/api/src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheckService,
  HealthCheck,
  TypeOrmHealthIndicator,
} from '@nestjs/terminus';
import { RedisHealthIndicator } from './redis-health.indicator';

@Controller('health')
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
    private readonly redis: RedisHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.redis.isHealthy('redis'),
    ]);
  }
}

This endpoint checks the dependencies the API actually needs to serve a request, not just whether the Node process is alive. A process can run perfectly fine while its database connection pool is exhausted, and a health check that only pings the process itself will happily route traffic to an instance that can't serve it.

Point your platform's readiness probe at this endpoint and give it a real grace period before marking an instance unhealthy. On ECS, that's the target group's health check on the load balancer; behind Nginx in Compose it's a manual healthcheck, though Compose alone doesn't give you the traffic draining a real load balancer does. The platform should wait for a new instance's health check to pass, then drain in-flight connections from the old instance rather than killing it outright. That draining step is the actual mechanism behind "zero-downtime," not anything magical about the deploy command itself.

Database migrations as part of the deploy

Migrations are where deploys go wrong in ways that are hard to notice until they've caused an incident. The core rule: a migration has to be safe while the previous version of the code is still serving traffic, because during a rolling deploy, old and new code run side by side for some window of time, however short.

That rules out dropping a column the old code still reads, or renaming something the old code references by its previous name, in the same deploy that ships the code change. The safe pattern splits it into stages: add the new column, deploy code that writes to both old and new, backfill, deploy code that reads only the new column, then drop the old one in a later migration once nothing depends on it.

-- Stage 1: additive, safe with old code still running
ALTER TABLE organizations ADD COLUMN billing_email VARCHAR(255);

-- Stage 2, a later migration, once new code is the only code running:
-- ALTER TABLE organizations DROP COLUMN legacy_billing_contact;

Run migrations as an explicit release step, not something the app does on boot. Letting every instance run migrations on startup means N instances racing to apply the same migration during a rolling deploy, an intermittent failure that's miserable to debug because it only shows up under real deploy timing.

# One-off migration step before the ECS service update in deploy.yml
- name: Run migrations
  run: |
    aws ecs run-task \
      --cluster saas-production \
      --task-definition saas-migrate:${{ github.sha }} \
      --launch-type FARGATE \
      --network-configuration "..." \
      --overrides '{"containerOverrides":[{"name":"migrate","command":["npm","run","migration:run"]}]}'

Running it as a separate task before the service update means the schema is ready before any new code that depends on it receives traffic. It also happens exactly once, regardless of how many instances the service scales to.

Rollback and production pitfalls

Because every image is tagged with a git SHA, rolling back is the same command as rolling forward, pointed at a previous tag:

aws ecs update-service \
  --cluster saas-production \
  --service api \
  --force-new-deployment \
  --task-definition saas-api:a1b2c3d

Keep that command, or its equivalent, somewhere every engineer can find it without digging, ideally in a runbook rather than someone's shell history. The value of a fast rollback comes entirely from how quickly someone can find and run it under pressure.

A few pitfalls show up repeatedly once a team deploys for real. Coupling migrations and code into one inseparable step is the big one, already covered above. Skipping a genuine health check in favor of a bare liveness probe is another: it hides exactly the failures, a dead connection pool, an unreachable Redis, that a deploy is most likely to introduce. Not tagging images with something traceable to a commit turns rollback into guesswork. And treating staging as optional because "it's basically the same as prod" removes the one environment where a broken migration gets caught before it reaches customers.

It comes down to deciding, deliberately, what "safe to deploy" means for your app, then encoding that decision into the pipeline so it doesn't depend on someone remembering it under pressure. No exotic tooling required.

Key takeaways

  • Match deployment complexity to your actual team and traffic: a single VM with Docker Compose is a legitimate production target, ECS/Cloud Run/a PaaS is the right next step for most teams, and Kubernetes should wait for a concrete multi-service, multi-team problem.
  • Build one artifact per commit, tag it with the git SHA, and promote that same artifact through environments instead of rebuilding per environment.
  • A real health check verifies dependencies (database, Redis), not just that the process is alive; the platform should wait for it before draining the previous instance's traffic.
  • Run database migrations as an explicit, single release step before the code deploy, and design schema changes to be safe while old and new code run side by side.
  • Rollback should be the same mechanism as deploy, pointed at a previous SHA; keep that command in a runbook, not in someone's memory.
  • Deploying isn't about clever tooling. It's about deciding what "safe" means for your app and encoding that decision into the pipeline.

Frequently asked questions

Do I need Kubernetes to deploy a production SaaS?

No. Kubernetes solves orchestration problems that show up with many services and teams. A single VM with Docker Compose, or a managed platform like ECS Fargate or Cloud Run, is a legitimate production target until a concrete multi-service problem justifies the cost.

How do I achieve zero-downtime deploys?

The platform waits for a new instance's health check to pass before routing traffic to it, then drains in-flight connections from the old instance before terminating it. That depends on a health check verifying real dependencies and a load balancer that supports draining, not on any single deploy command.

Should database migrations run automatically when the app starts?

No. Running migrations on boot means every instance in a rolling deploy races to apply the same one. Run migrations as a single, explicit step before the code deploy, ideally as a one-off task rather than baked into startup.

What's the fastest way to roll back a bad deploy?

Tag every built image with its git SHA and keep a runbook command that redeploys a previous tag. Because the artifact for any past commit already exists, rollback is the same operation as a forward deploy, just pointed at an older tag.

Is it safe to rename or drop a database column in the same deploy as the code change?

Not if the deploy is rolling, since old and new code briefly run side by side. Split the change: add the new column, deploy code that writes both, backfill, deploy code that reads only the new column, then drop the old one once nothing references it.

When should I move from a single VM to a managed platform like ECS or Cloud Run?

When keeping the VM healthy and scaled starts costing real engineering time, or when you need more than one instance and want the platform to handle health checks and traffic shifting for you. It's a reasonable second step for most teams, well before Kubernetes is worth it.

Related articles

Deploying NestJS to Production — Aman Kumar Singh
Configuration and Environment — Aman Kumar Singh
Monitoring a SaaS in Production — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.