Designing a CI/CD Pipeline for a TypeScript SaaS Application with GitHub Actions
Build a practical GitHub Actions pipeline for a TypeScript SaaS: fast checks, PostgreSQL tests, immutable releases, staging, production approval, and safe recovery.
A pull request passes its tests. Someone merges it. The deployment rebuilds the application, runs a database migration, and restarts the service.
Ten minutes later, customers cannot open their invoices.
The pipeline was green because it tested the source code. It did not establish that the release could start with the real schema, serve its frontend assets, or coexist with the previous version during rollout.
A useful CI/CD pipeline gives you evidence for those questions. It also leaves a clear path back when the evidence was incomplete.
Let's build that pipeline for a small TypeScript SaaS application using GitHub Actions. The example has a web frontend, a Node.js API, and PostgreSQL. It uses npm with a committed lockfile, and packages the frontend and API into one release. The deployment platform is deliberately behind a small adapter so that the release principles remain visible.
Define what a green pipeline means
Before writing YAML, decide what the result should prove.
For a pull request, green should mean the change installs from the lockfile, passes static checks, preserves important behaviour, works against the expected database engine, and produces a working production build.
For a release, green should also mean that an identified artifact reached staging, passed deployment checks, and reached production through the agreed gate. A successful upload alone is not a successful deployment.
The flow is straightforward:
Pull request or main push
-> install -> lint -> types -> unit tests
-> database tests -> build -> browser smoke tests
Main push only
-> package that build -> store artifact + checksum
-> staging -> smoke test
-> production approval -> production -> smoke test
Start with this path before adding preview environments, large test matrices, or elaborate release automation. Every extra branch in the pipeline is another behaviour someone must understand during an incident.
Give the application a clear command contract
GitHub Actions should orchestrate commands that developers can also run locally. Keep application details in the repository instead of spreading them across shell fragments in the workflow.
For this example, the root package exposes these commands:
| Command | Required behaviour |
|---|---|
npm run lint | Check source without rewriting it |
npm run typecheck | Check frontend and backend TypeScript without emitting files |
npm run test:unit | Run deterministic unit tests once, without watch mode |
npm run db:migrate:test | Apply migrations to the disposable database in DATABASE_URL |
npm run test:integration | Exercise real database behaviour with isolated fixtures |
npm run build | Compile the API and frontend for production |
npm run test:e2e | Start that build and run a small Playwright suite |
npm run release:pack | Package the existing build and its runtime requirements without rebuilding |
These are contracts for the example application, not built-in npm features. Implement them for your project before copying the workflow. In particular, the browser-test configuration must start the production build, wait for readiness, and shut it down when the tests finish.
Commit a .node-version file containing the supported Node.js 22 patch version used by the application, and keep it updated. Commit package-lock.json too. If installation needs a repository setting such as legacy-peer-deps, commit that setting so that local and CI installs agree.
Use npm ci in CI. It is intended for clean, lockfile-based installation and fails when the package manifest and lockfile do not match. The npm ci documentation explains the behaviour and configuration requirements.
Start with a workflow you can read
Put the following in .github/workflows/saas.yml in the example application's repository. The deployment jobs in the next section belong under the same jobs key. A combined workflow download keeps the full example in one file.
name: SaaS delivery
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: saas-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 25
outputs:
archive-sha256: ${{ steps.package.outputs.sha256 }}
release-artifact-id: ${{ steps.upload.outputs.artifact-id }}
services:
postgres:
image: postgres:18.3
env:
POSTGRES_DB: saas_test
POSTGRES_USER: saas_test
POSTGRES_PASSWORD: local_test_only
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U saas_test -d saas_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
CI: 'true'
DATABASE_URL: postgres://saas_test:local_test_only@localhost:5432/saas_test
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: .node-version
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run test:unit
- run: npm run db:migrate:test
- run: npm run test:integration
- run: npm run build
- run: npx --no-install playwright install --with-deps chromium
- run: npm run test:e2e
- name: Package the verified build
id: package
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
npm run release:pack -- --output release.tgz --revision "$GITHUB_SHA"
test -s release.tgz
checksum=$(sha256sum release.tgz | cut -d ' ' -f 1)
echo "sha256=$checksum" >> "$GITHUB_OUTPUT"
- name: Store release
id: upload
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-${{ github.sha }}-${{ github.run_attempt }}
path: release.tgz
if-no-files-found: error
retention-days: 14
- name: Store browser failure evidence
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: browser-failure-${{ github.run_id }}-${{ github.run_attempt }}
path: |
playwright-report/
test-results/
if-no-files-found: ignore
retention-days: 7
The database password is for a short-lived test container with synthetic data. It is not a production credential. Production secrets do not belong in this job.
Because the job runs directly on an Ubuntu runner, it connects to the published database port at localhost. If you move the job itself into a container, the networking changes: you normally use the service label as the hostname. GitHub's PostgreSQL service-container guide explains both arrangements.
The actions are pinned to verified release commits, with readable version comments. These are explicit example versions, not a claim that they are the latest releases. Review and update pins as part of dependency maintenance. Self-hosted runners also need to meet each action's runtime requirements.
Test the boundaries that TypeScript cannot protect
Type checking is valuable, but it does not establish that a migration is valid or that a query respects tenant boundaries. The strict-mode article covers what the compiler can help with. Here, use database tests for the remaining promises.
For an invoice feature, I would include a test that one company's user cannot read another company's invoice, a test that duplicate payment events do not apply the same state change twice, and a test that a failed transaction leaves no partial update.
Run those against PostgreSQL rather than replacing it with a different database just because that database starts faster. Constraint behaviour, SQL syntax, and transaction semantics are part of what you are testing.
Keep fixtures predictable. Each test should own its data, use rollback where appropriate, or isolate its schema. Parallel tests sharing the same mutable invoice can create failures that have nothing to do with the application.
The browser suite should be narrow enough to trust. Sign in with a test account, create one important record, open it, and confirm the expected result. A small suite covering the production build often gives more useful release evidence than hundreds of fragile tests that the team routinely reruns until green.
Build once and identify the release
The packaging step runs after the production build and browser tests in the same job. It must package those outputs, not invoke another build behind the scenes.
For this example, release.tgz contains the API runtime files, frontend assets, migrations, production runtime dependencies where needed, and a manifest containing the source commit. It excludes test fixtures, local environment files, and credentials. Package only an explicit allowlist of paths.
The archive must match the deployment runtime. Native Node.js modules built on Ubuntu are not automatically portable to Alpine Linux or another architecture. If your deployment uses containers, make the tested image digest the release identity instead of moving a tar archive between incompatible systems.
The upload returns an artifact ID, which both deployment jobs use. Including the run attempt in the artifact name avoids a collision when verification is rerun; downloading by the recorded ID also avoids guessing which upload belongs to the release.
The checksum ties the downloaded archive to the one created by the verification job. It detects an unexpected artifact mismatch; it does not prove that the source code or build process is trustworthy. Branch protection, reviewed workflows, and controlled dependencies still matter.
Configuration is another common trap. A frontend that embeds its API URL at build time cannot be promoted unchanged from staging to production if those URLs differ. For this example, the frontend uses relative API paths and obtains any other public configuration at runtime. Secrets stay on the server. Choose that configuration model deliberately before promising to promote identical bytes.
Promote the same artifact through two environments
Create GitHub environments named staging and production. Configure deployment branch restrictions. Configure the production review gate in repository settings where your repository and plan support it; naming an environment in YAML does not enable approval by itself. GitHub documents the available controls under deployment environments.
Add these two jobs under the existing jobs key, alongside verify. The jobs line below shows the nesting; do not create a second jobs key in the file:
jobs:
# Keep the verify job from the previous block here.
staging:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: verify
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: staging
permissions:
contents: read
id-token: write
env:
RELEASE_SHA: ${{ github.sha }}
EXPECTED_SHA256: ${{ needs.verify.outputs.archive-sha256 }}
RELEASE_TARGET: ${{ vars.RELEASE_TARGET }}
BASE_URL: ${{ vars.BASE_URL }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: .node-version
package-manager-cache: false
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
artifact-ids: ${{ needs.verify.outputs.release-artifact-id }}
merge-multiple: true
path: release
- name: Verify the release bytes
run: printf '%s %s\n' "$EXPECTED_SHA256" release/release.tgz | sha256sum --check --strict
- run: node scripts/deploy.mjs --environment staging --archive release/release.tgz --revision "$RELEASE_SHA"
- run: node scripts/smoke.mjs --base-url "$BASE_URL" --revision "$RELEASE_SHA"
production:
needs: [verify, staging]
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: production
permissions:
contents: read
id-token: write
env:
RELEASE_SHA: ${{ github.sha }}
EXPECTED_SHA256: ${{ needs.verify.outputs.archive-sha256 }}
RELEASE_TARGET: ${{ vars.RELEASE_TARGET }}
BASE_URL: ${{ vars.BASE_URL }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: .node-version
package-manager-cache: false
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
artifact-ids: ${{ needs.verify.outputs.release-artifact-id }}
merge-multiple: true
path: release
- name: Verify the release bytes
run: printf '%s %s\n' "$EXPECTED_SHA256" release/release.tgz | sha256sum --check --strict
- run: node scripts/deploy.mjs --environment production --archive release/release.tgz --revision "$RELEASE_SHA"
- run: node scripts/smoke.mjs --base-url "$BASE_URL" --revision "$RELEASE_SHA"
The deployment and smoke scripts are application-specific adapters. They are not included implementations of an AWS, Azure, or other cloud deployment. The YAML defines where they run and what they receive. Do not treat it as a complete deployment until those adapters and environment settings exist.
For this example, both scripts use Node.js built-ins and provider REST endpoints, so the deployment jobs need no npm ci. If your adapters need a cloud CLI or SDK, add a reviewed, versioned installation step rather than assuming it exists on the runner.
deploy.mjs must validate the target, obtain the appropriate short-lived cloud credential, take an environment release lock, verify the archive manifest, apply compatible migrations, deploy the identified artifact, and wait for readiness. It must fail with a nonzero exit code if any required step fails. It must also reject a release older than the environment's current revision unless an explicit rollback operation authorises it.
smoke.mjs must check the deployed revision, readiness, frontend assets, and a minimal application operation using a dedicated synthetic account where authentication is needed. Give every network request a timeout. Store any test-account credentials as environment secrets and expose them only to the smoke step that needs them.
Keep cloud access out of pull-request jobs
A pull request can change code that the workflow executes. Do not give that code production credentials merely because the workflow file looks familiar.
The verification job has read-only repository permissions and no cloud identity permission. Release jobs run only after a successful main-branch verification. Avoid using pull_request_target to check out and execute untrusted pull-request code with privileged credentials. GitHub's secure-use guidance covers these trust boundaries.
OIDC lets a workflow exchange an identity token for a short-lived cloud credential. The id-token: write permission allows requesting that identity token; it does not grant cloud access on its own. Configure the cloud trust policy to require the intended audience and the exact repository and environment identity. Give staging and production separate roles with only the operations they need. See GitHub's OIDC documentation.
Protect the default branch and review changes to workflows and deployment scripts carefully. An environment approval is useful only when the reviewer can identify which commit and artifact they are approving.
Treat database migrations as a compatibility problem
An application rollout can temporarily run old and new versions together. A migration must respect that overlap.
Suppose you want to rename customers.name to display_name. Renaming the column immediately can break the old application while its instances are still serving requests.
A safer sequence is to add the new column, deploy code that can work with both representations, backfill existing rows, switch reads when the data is ready, and remove the old column in a later release. The exact dual-write or synchronisation mechanism depends on your application, but the overlap must be designed rather than assumed away.
Test migrations against an empty database and an upgrade fixture representing the previous production schema. These answer different questions. A migration chain can build a fresh database successfully while failing on old rows that violate a new constraint.
Run migrations once per release under a lock. Do not let every new application instance compete to run them. Large backfills may need their own resumable job instead of occupying the deployment's critical path.
Application rollback also has limits. Redeploying the previous artifact does not restore a dropped column or undo a destructive data transformation. Prefer changes that preserve compatibility during the rollback window, and write down when recovery requires a forward fix or data restoration instead.
Make concurrency and cancellation deliberate
Cancelling an old pull-request run when a new commit arrives is usually helpful. Cancelling a production deployment halfway through a migration can leave work in an uncertain state.
The workflow cancels in-progress runs only for pull requests. Main-branch runs share one concurrency group, so staging and production promotion remain serialised through the complete release flow, including approval waits.
This is intentionally simple, but it has a trade-off: a slow production approval holds up later releases. Also, GitHub's default concurrency behaviour keeps at most one pending run per group and can replace that pending run with a newer one. It is not a first-in, first-out release queue. Read the concurrency documentation before relying on ordering.
For a small SaaS team, deploying the latest pending main commit can be fine. If every revision must be deployed, use a release controller with explicit queue semantics. In either case, keep the deployment lock and stale-release check in the adapter so that a manual rerun or another deployment path cannot silently move production backwards.
Use smoke tests to decide whether a release is healthy
A process listening on a port proves very little. A useful readiness check should establish that the new instance can serve requests and reach required dependencies within a bounded time.
After deployment, check the revision as well as the HTTP status. Otherwise, an old instance or cached response can make a failed rollout appear healthy.
For the invoice example, the smoke test might sign in with a synthetic account, open its invoice list, and fetch one seeded invoice. Keep synthetic data isolated from real customers. Do not charge a card or send an actual customer notification as part of a routine deployment test.
Decide what happens on failure. The workflow above stops and reports a failed deployment; it does not contain automatic rollback. The operator can then use the previous retained artifact if the migration compatibility policy permits it. Add automatic rollback only when its trigger, target revision, and data compatibility checks are explicit and tested.
Continue observing error rates and latency after the smoke test passes. A brief test cannot reproduce every customer workflow or production traffic pattern.
Make the pipeline faster after it is trustworthy
Start by measuring where the time goes. Dependency installation, browser installation, database setup, tests, and builds need different improvements.
setup-node's npm cache saves package-manager data; it does not replace npm ci or guarantee that dependencies are already installed. Its caching documentation explains the distinction. Keep caches disposable and keep secrets out of them.
Once the basic pipeline is stable, linting, type checking, and unit tests can move into independent jobs. Integration tests can have their own database service. Keep the production build and the tests that validate it connected to the artifact that will be released.
Be cautious with path filters on required checks. If a workflow is skipped entirely, the expected check can remain pending and block merging. If you introduce selective execution, keep a predictable required status that correctly accounts for the checks the change needed.
Speed is useful when the team can still explain what passed. Saving two minutes by skipping the only test of the production build is rarely a good trade.
Leave a release trail the team can follow
Record the commit, artifact checksum, migration version, target environment, deployment result, and smoke-test result for each release. Keep enough previous artifacts to support the team's recovery window. Fourteen days in this example is a starting policy, not a substitute for a longer-term release registry if your recovery needs one.
Require the verify job before merging and keep failures visible. If a flaky test is quarantined, give that decision an owner and a review date. Quietly ignoring failing checks teaches everyone that a green badge is optional evidence.
The pipeline should make a release easy to identify and a failure easier to investigate. When an invoice page breaks, you should be able to answer which bytes are running, what was tested, which schema change accompanied them, and how to recover. That is a much stronger outcome than simply automating the deploy command.
