Setting Up CI/CD for a Full Stack Monorepo
In Multi-Tenancy Architecture for SaaS we worked through how a single deployment serves many organizations without their data or traffic bleeding into each other. That article was about the shape of the data and the request path. This one is about what happens the moment you need to ship a change to that system. You need to do it without taking the system down, and without waking up at 2am because a migration ran against production before the code depending on it finished deploying.
This is part of the Full Stack SaaS Masterclass, a build-it-for-real series that takes a multi-tenant SaaS from an empty folder to production. We're now in Module 4, the production module, and CI/CD is the right place to start it. Everything else in this module, monitoring, logging, feature flags, scaling, assumes you already have a pipeline that reliably gets code from a pull request into a running environment.
I'm assuming the monorepo layout from earlier in the series: a Next.js frontend, a NestJS backend, and a handful of shared packages, all managed with Turborepo. If you're on a different monorepo tool the specifics change but the reasoning underneath doesn't.
Why CI/CD is a different problem in a monorepo
In a single-app repo, "run the pipeline" and "test everything" are the same instruction. In a monorepo they aren't, and treating them as the same thing is the most common mistake I see teams make early on. A commit that only touches a shared UI component has no business rebuilding your NestJS API and running its full integration suite. Do this naively and your pipeline gets slower every time you add a package, since you're running work you never needed to do in the first place.
The other thing that changes is blast radius. A shared packages/types package used by both the frontend and backend means a single commit can be structurally coupled to both apps even if the diff only touches one file. Your pipeline has to understand that dependency graph, not just the list of files that changed. This is exactly the problem Turborepo's task graph solves. It's why "just use npm run test in a GitHub Actions job" stops being a reasonable answer once you have more than two or three packages.
The goal at this stage is deliberately modest: fast, correct feedback on pull requests, and a deploy step that only ships what changed, gated on tests actually passing. Don't reach for canary deployments or a custom deployment orchestrator yet. Those solve problems you don't have while you're still onboarding your first handful of tenants, and adding them now is complexity you'll pay for before you see any of the benefit.
Structuring the pipeline around what changed
Turborepo already knows your dependency graph from turbo.json, so the pipeline's job is to ask it "what needs to run for this change" instead of running everything unconditionally.
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"lint": {
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"test:e2e": {
"dependsOn": ["build"],
"outputs": []
}
}
}
The dependsOn field is the part worth understanding, not just copying. "^build" means "build this package's dependencies first," which is what lets apps/api safely assume packages/types is already compiled before it starts compiling itself. Without that, you either build in the wrong order or you build everything from scratch every time, which defeats the purpose of a task graph.
The affected-only piece comes from Turborepo's filtering, using git as the source of truth for what changed:
turbo run build lint test --filter=...[origin/main]
That [origin/main] filter tells Turborepo to look at what's different between the current branch and main, then run the given tasks against every package that changed plus everything downstream of it. A change to packages/ui triggers a rebuild of the frontend that consumes it; a change buried only in the API's own source doesn't touch the frontend at all. This is the single biggest lever for keeping CI fast as the monorepo grows. It costs nothing extra to set up, since you already have the dependency graph for local development.
A GitHub Actions workflow for a Turborepo monorepo
With the task graph doing the filtering, the workflow itself stays simple: one job for verification, one job for deployment, gated on the first one passing and the branch being main.
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Lint, test, and build affected packages
run: npx turbo run lint test build --filter=...[origin/main]
deploy:
needs: verify
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
- name: Log in to Amazon ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push API image
run: |
docker build -f apps/api/Dockerfile -t ${{ steps.ecr.outputs.registry }}/saas-api:${{ github.sha }} .
docker push ${{ steps.ecr.outputs.registry }}/saas-api:${{ github.sha }}
- name: Build and push web image
run: |
docker build -f apps/web/Dockerfile -t ${{ steps.ecr.outputs.registry }}/saas-web:${{ github.sha }} .
docker push ${{ steps.ecr.outputs.registry }}/saas-web:${{ github.sha }}
- name: Run database migrations
run: |
npx turbo run db:migrate --filter=@saas/api
- name: Update ECS services
run: |
aws ecs update-service --cluster saas-prod --service saas-api --force-new-deployment
aws ecs update-service --cluster saas-prod --service saas-web --force-new-deployment
Two decisions here are worth calling out. First, role-to-assume uses OIDC federation instead of long-lived AWS access keys stored as secrets. GitHub's OIDC provider issues a short-lived token that AWS trusts based on a configured IAM role trust policy, so there's no static credential sitting in your repository settings for someone to leak. Second, both Docker images are tagged with github.sha rather than latest, which gives you a traceable artifact for every deploy and makes rollback a matter of pointing the ECS service back at a previous tag.
Deploying safely: migrations, environments, and rollout order
The workflow above glosses over the part that actually causes outages: the order in which the migration, the API, and the frontend go live relative to each other.
Run migrations before you deploy new application code, never after, and make sure they're backward compatible with the version of the API that's still running. If you're adding a new required column, add it as nullable first, backfill it, and only make it required in a later deploy once every running instance is writing it. This two-step pattern feels slow if you're used to migrating and deploying in lockstep. But in a system with rolling deployments, there's always a window where old and new code run side by side. A migration that assumes the new code is already live will break the old code still serving requests during that window.
Environment promotion is the other piece worth setting up before you need it. A staging environment that mirrors production, same Docker images, same migration path, different database and different AWS account or VPC, lets you catch a broken migration or a misconfigured environment variable before it reaches paying tenants. The workflow structure barely changes: the same deploy job runs against staging on every merge to main, and a separate, manually triggered workflow promotes a specific image tag from staging to production once someone has actually looked at it. Don't skip the manual gate to production this early. An automated staging-to-production pipeline is a reasonable target once you have real deployment history, but bolting it on before then just moves the risk from "a person approves this" to "nobody approves this."
Production pitfalls
The most expensive mistake is coupling deploy order to API compatibility without meaning to. If your frontend deploy finishes before your API deploy and immediately calls a new endpoint the API doesn't have yet, users see errors for however long the gap lasts. Deploy the API first, confirm it's healthy, then deploy the frontend. Pushing both images and updating both ECS services in the same job with no ordering guarantee builds this bug straight into the pipeline.
Turborepo's local cache can also quietly go stale if it's keyed incorrectly. If your Docker build doesn't include the lockfile in its cache key, you can end up shipping a container built against dependency versions that no longer match what's installed. Always include package-lock.json in the Docker build context and let Docker's own layer caching decide when to reinstall.
Secrets sprawl is the third one. Once you have a frontend app, an API, a migration job, and a staging environment on top of production, it's easy to end up with the same database URL or API key duplicated across several places in GitHub's environment secrets. When one needs to rotate, someone forgets a copy, and you get an intermittent failure that only shows up in one environment. Centralize secrets in one place, AWS Secrets Manager or Parameter Store is a reasonable default here, and have both CI and the running containers read from there instead.
Finally, don't gate a production deploy on "the build succeeded" alone. A green build tells you the code compiles and the tests you wrote pass. It says nothing about whether the migration you're about to run is safe against the current shape of production data. For anything touching a column used across tenants, review the migration by hand before it goes near the deploy job.
Key takeaways
- Use Turborepo's dependency graph and `--filter=...[origin/main]` to test and build only what a change actually affects, not the whole monorepo on every push.
- Tag deploy artifacts with the git SHA, not `latest`, so rollback means pointing at a previous tag rather than reconstructing history.
- Use OIDC federation for AWS credentials in CI instead of long-lived access keys stored as GitHub secrets.
- Deploy the API before the frontend, and design migrations to be backward compatible during the rollout window rather than assuming instant cutover.
- Centralize secrets in one store that both CI and running containers read from, instead of duplicating them across environments.
- A green pipeline confirms your code compiles and your tests pass. It does not confirm a schema migration is safe against real production data.
Frequently asked questions
Do I need a monorepo-specific CI setup, or can I just run the full test suite on every commit?
You can run the full suite every time and it will work correctly. The tradeoff is that CI time grows with the size of the monorepo rather than the size of the change, so a one-line fix eventually takes as long to verify as a major feature. Filtering by what actually changed, using Turborepo's dependency graph, keeps feedback time tied to the change itself.
Should database migrations run inside the same CI job as the deploy, or separately?
Run them as their own step, before the application deploy step, so a failed migration stops the deploy instead of running against a database in a half-migrated state.
How do I avoid downtime when the API and frontend deploy at slightly different times?
Deploy the API first and make new endpoints additive rather than replacing old ones outright during the rollout window. The frontend should keep working against the previous API version for the short period between the two deploys finishing.
Is it safe to use GitHub Actions secrets for AWS credentials?
It works, but a long-lived access key sitting in repository secrets is a standing risk if it ever leaks through a misconfigured workflow. OIDC federation removes that risk by issuing a short-lived, scoped token per workflow run instead of a static credential.
When should I add a staging environment if I don't have one yet?
As soon as you have paying tenants whose data you can't afford to break with a bad migration. Before that, a well-tested local Docker Compose setup plus careful manual review of migrations is a reasonable stand-in.
Explore more on

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.