DEV Community

Cover image for Build a Full CI/CD Pipeline for a Node.js App: From Zero to Auto-Deploy in One Hour
Vishvesh Patel for DevOpsLesson

Posted on

Build a Full CI/CD Pipeline for a Node.js App: From Zero to Auto-Deploy in One Hour

Push your code to GitHub, watch it test itself, build a Docker image, and deploy to a live URL automatically. Here’s the exact project I built, and how you can copy it step by step.

If you’ve ever pushed code to production by SSH-ing into a server and running git pull by hand, this article is for you. By the end of this weekend project, a single git push will trigger a pipeline that runs your tests, builds a Docker image, pushes it to a registry, and deploys your app to a live URL with zero manual steps.

No prior CI/CD experience required. You’ll need about four hours, a GitHub account, and a willingness to watch a pipeline turn green for the first time (it’s genuinely satisfying).

If you want a quick refresher on what CI/CD actually is before diving into code, this introduction to CI/CD pipelines is a solid primer on the concepts we’re about to put into practice.

🌍 Website

What You’ll Build

Here’s the full pipeline we’re wiring up:

What you’ll build by end of this project

  1. Continuous Integration :- Every push runs the test suite in GitHub Actions.
  2. Containerization :- A Docker image is built from your app.
  3. Registry push :- The image is pushed to GitHub Container Registry (GHCR).
  4. Continuous Deployment :- The new image is deployed automatically to Fly.io, giving you a public URL.

The whole thing lives in one YAML file and runs on GitHub’s free tier.

Architecture of the project

Prerequisites

Before we start, make sure you have:

Step 1: Build a Minimal Node.js App

Let’s keep the app tiny so we can focus on the pipeline, not the code. Create a project folder and initialize it:

mkdir node-cicd-demo && cd node-cicd-demo
npm init -y
npm install express
npm install --save-dev jest supertest`

Create app.js:

`const express = require("express");
const app = express();

app.get("/", (req, res) => {
  res.json({ message: "Hello from my CI/CD pipeline!", status: "healthy" });
});

module.exports = app;
Enter fullscreen mode Exit fullscreen mode

Create server.js to start it:

const app = require("./app");
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Enter fullscreen mode Exit fullscreen mode

Run node server.js and visit http://localhost:3000. You should see your JSON response. That's the app. Now let's make it testable.

Step 2: Add a Test (This Is the “CI” Part)

Continuous Integration means your tests run automatically on every change. Create app.test.js:

const request = require("supertest");
const app = require("./app");

describe("GET /", () => {
  it("responds with a healthy status", async () => {
    const res = await request(app).get("/");
    expect(res.statusCode).toBe(200);
    expect(res.body.status).toBe("healthy");
  });
});
Enter fullscreen mode Exit fullscreen mode

Update the scripts block in your package.json:

"scripts": {
  "start": "node server.js",
  "test": "jest"
}
Enter fullscreen mode Exit fullscreen mode

Run npm test. Green checkmark? Good. This exact command is what our pipeline will run on every push.

Step 3: Containerize the App with Docker

A container guarantees your app runs the same way on your laptop and on the server. Create a Dockerfile:

FROM node:20-alpine
WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

EXPOSE 3000
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

A few things worth noting: we copy package*.json before the rest of the code so Docker can cache the dependency layer, and we use npm ci for reproducible installs. Before you commit, it's worth running your Dockerfile through this free Dockerfile linter to catch common mistakes like missing .dockerignore entries or inefficient layer ordering.

Speaking of which, add a .dockerignore file:

node_modules
npm-debug.log
.git
Enter fullscreen mode Exit fullscreen mode

Test it locally:

docker build -t node-cicd-demo .
docker run -p 3000:3000 node-cicd-demo
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:3000 again. Same response, now running inside a container.

Step 4: Push Your Project to GitHub

Create a new repository on GitHub, then:

git init
git add .
git commit -m "Initial commit: Node app with tests and Dockerfile"
git branch -M main
git remote add origin https://github.com/YOUR_USERNAME/node-cicd-demo.git
git push -u origin main
Enter fullscreen mode Exit fullscreen mode

Your code is now on GitHub. Nothing is automated yet. Let’s fix that.

Step 5: Write the GitHub Actions Workflow

Github actions job chain

This is the heart of the project. GitHub Actions reads YAML files from a .github/workflows/ folder. Create .github/workflows/ci-cd.yml:

name: CI/CD Pipeline

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm test

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: flyctl deploy --remote-only
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

Notice how the three jobs chain together with needs: build-and-push only runs if test passes, and deploy only runs if the build succeeds. That's your safety net: broken code never reaches production.

YAML is famously whitespace-sensitive, and a single misplaced indent will break the whole run. Before committing, paste your workflow into this YAML validator to make sure it parses cleanly — it’ll save you a failed run and a confusing error message.

Step 6: Set Up Fly.io for Deployment

Install the Fly CLI and launch your app (this generates a fly.toml config without deploying yet):

# macOS/Linux
curl -L https://fly.io/install.sh | sh

flyctl auth login
flyctl launch --no-deploy
Enter fullscreen mode Exit fullscreen mode

Follow the prompts (pick a region, skip the database). When it finishes, generate a deploy token:

flyctl tokens create deploy
Enter fullscreen mode Exit fullscreen mode

Copy that token. In your GitHub repo, go to Settings → Secrets and variables → Actions → New repository secret, name it FLY_API_TOKEN, and paste the token in.

Step 7: Watch the Magic Happen

Commit your workflow and push:

git add .github/workflows/ci-cd.yml fly.toml
git commit -m "Add CI/CD pipeline"
git push
Enter fullscreen mode Exit fullscreen mode

Now open the Actions tab in your GitHub repo. You’ll see your pipeline running live. The test job spins up, then the build, then the deploy. When all three go green, grab your live URL:

flyctl status
Enter fullscreen mode Exit fullscreen mode

Visit it. Your app is live, deployed entirely by a git push. You now have a real CI/CD pipeline.

To prove it, change the message in app.js, commit, and push again. Watch the pipeline redeploy automatically. That feedback loop code, push, live in minutes is the entire point of CI/CD.

Deploying to a VPS Instead (Optional)

Prefer a cheap VPS over Fly.io? Swap the deploy job for an SSH-based one using the appleboy/ssh-action, store your server's SSH key as a secret, and have the job pull the new image and restart the container. The CI and build stages stay identical only the final hop changes.

Common Errors (Bookmark This Section)

  • npm ci fails in Actions but works locally. You forgot to commit package-lock.json. Commit it.

  • denied: permission_denied when pushing to GHCR. Add the packages: write permission block to the job (it's in the workflow above).
    YAML “did not find expected key” error. An indentation problem. Run it through a YAML validator.

  • Fly deploy hangs or fails. Check that your EXPOSE port in the Dockerfile matches the internal port in fly.toml (both should be 3000).

  • Tests pass but deploy uses old code. Your image tag is cached. Tag images with the commit SHA (${{ github.sha }}) instead of latest for guaranteed freshness.

Where to Go Next

You’ve built the foundation. To level up this pipeline, try adding:

  • Staging and production environments with branch-based deploys
  • Automated rollbacks when a health check fails
  • Container image scanning for security vulnerabilities
  • Deployment notifications to Slack or Discord

If you’re using this project as a stepping stone into a DevOps career, it maps neatly onto the CI/CD and containerization stages of this DevOps Engineer roadmap, which lays out what to learn next in a sensible order.

Wrapping Up

A weekend ago, deploying meant SSH sessions and crossed fingers. Now it’s a git push. You've connected five tools Node.js, Jest, Docker, GitHub Actions, and Fly.io into one automated pipeline that tests, builds, and ships your code without you touching a server.

The best part? This same pattern scales. The pipeline that deploys a toy Express app is structurally identical to the one shipping production services at real companies. You just built the real thing, small.

Found this useful? Follow for more hands-on DevOps projects, and drop a comment with your live URL I’d love to see what you built.

Top comments (0)