Build a GitHub Actions pipeline for a Node.js service: tests on every pull request, a container image per commit and gated deployments using OIDC.
In this article
- 01What the pipeline will do
- 02Step 1: Triggers, permissions and concurrency
- 03Step 2: The test job
- 04Step 3: Real dependencies with service containers
- 05Step 4: Build and push a container image
- 06Step 5: Authenticate to your cloud with OIDC, not stored keys
- 07Step 6: Deploy with environments and approvals
- 08Step 7: Database migrations and safe rollbacks
- 09Speeding up slow pipelines
- 10Security and reliability habits
- 11Scheduled jobs and next steps
What the pipeline will do
A good pipeline answers two questions automatically: is this change safe to merge, and can we ship it without manual steps? For a Node.js service, that means running lint, type checks and tests on every pull request, building a container image for every commit on the main branch and deploying it to staging automatically and to production after an approval.
GitHub Actions runs workflows defined in YAML files under .github/workflows in your repository. Each workflow has triggers, one or more jobs and steps inside each job. If you are comparing tools, our GitHub Actions vs Jenkins comparison covers the trade-offs, and our CI/CD explainer covers the concepts.
Step 1: Triggers, permissions and concurrency
Create .github/workflows/ci.yml. Under on, trigger on pull_request and on push to the main branch. Set top-level permissions to contents: read, so the default token can only read the repository; grant extra permissions per job only when needed.
Add a concurrency group keyed on the workflow and Git ref, with cancel-in-progress set to true. When someone pushes twice to the same pull request, the outdated run is cancelled, which saves minutes and avoids confusing results.
Step 2: The test job
Define a job named test with runs-on: ubuntu-latest. Its steps are short and reliable:
If the service must support several Node.js versions, for example during an upgrade, use a strategy matrix listing them. Otherwise test only the version you deploy, pinned in .nvmrc, so CI matches production exactly.
- actions/checkout to fetch the code
- actions/setup-node with node-version-file pointing to .nvmrc and cache set to npm, so dependency downloads are cached between runs
- npm ci, which installs exactly what package-lock.json specifies and fails if the lockfile is out of date
- npm run lint and npm run typecheck if you use TypeScript
- npm test, ideally producing a coverage report uploaded as an artifact
Step 3: Real dependencies with service containers
Integration tests are more useful against a real database than against mocks. Under the job, add a services section with a postgres container image, environment variables for the user, password and database, a port mapping and health check options using pg_isready so the job waits until the database accepts connections. Your tests connect through localhost and the mapped port, configured with an environment variable such as DATABASE_URL.
Run your migrations as a step before the tests, so every run starts from the same schema your production database will have.
Step 4: Build and push a container image
Add a build job with needs: test so it only runs after tests pass, and an if condition limiting it to pushes on main. Grant it packages: write if you push to GitHub Container Registry. Use docker/login-action to authenticate, docker/metadata-action to generate tags and docker/build-push-action to build and push.
Tag every image with the full commit SHA. That makes each deployment traceable to an exact commit and turns a rollback into redeploying an earlier tag. Enable the build cache in build-push-action, using the GitHub Actions cache backend, to keep builds fast.
Step 5: Authenticate to your cloud with OIDC, not stored keys
Long-lived cloud access keys stored as secrets are a common source of leaks. Instead, configure your cloud to trust GitHub's OpenID Connect provider. In AWS, create an IAM role whose trust policy allows tokens from your repository and branch or environment, then use aws-actions/configure-aws-credentials with role-to-assume in the deploy job. The job needs permissions id-token: write. Azure and Google Cloud support the same pattern through workload identity federation.
Step 6: Deploy with environments and approvals
Create two environments in the repository settings, staging and production. Add required reviewers to production, and optionally restrict it to the main branch. In the workflow, a deploy-staging job references environment: staging and runs automatically after the build; a deploy-production job references environment: production and pauses until a reviewer approves.
Environment-scoped secrets and variables keep staging and production configuration separate. The deploy step itself depends on your platform, for example updating an ECS service, applying a Kubernetes manifest or calling your hosting provider's CLI, followed by a smoke test against a health endpoint.
Step 7: Database migrations and safe rollbacks
Run database migrations as their own step or job, using the same image, before the new version receives traffic. Write migrations to be backward compatible, an approach often called expand and contract: add the new column, deploy code that writes to both old and new, backfill, and only remove the old column in a later release. That way, rolling back the application never requires rolling back the schema, which is the risky part.
Speeding up slow pipelines
Developers stop waiting for slow pipelines and start ignoring them. Keep feedback on pull requests fast:
- Run lint, type checks and tests as parallel jobs instead of one long job
- Split large test suites across a matrix of parallel runners
- Use paths filters so documentation-only changes skip the full pipeline
- Cache dependencies through setup-node and Docker layers through the build cache
- Share common steps across repositories with reusable workflows called through workflow_call
Security and reliability habits
A pipeline has access to your code and your production environment, so treat it as production infrastructure:
- Pin third-party actions to a full commit SHA, and official actions at least to a major version
- Never echo secrets; GitHub masks known secrets in logs, but derived values may not be masked
- Do not run workflows with secrets on pull requests from forks
- Protect the main branch and require the test job to pass before merging
- Use Dependabot or Renovate to keep actions and npm dependencies updated
- Set timeout-minutes on every job so a hung step cannot burn runner minutes for hours
Scheduled jobs and next steps
Workflows can also run on a schedule trigger with a cron expression, which is useful for nightly dependency audits or end-to-end tests. Remember that schedules run in UTC; our cron expression explainer helps check the timing.
Once the basics work, add preview environments for pull requests and automated database migration checks. Nexzem sets up pipelines like this for client projects through its CI/CD services and Node.js development work.



