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.
What You’ll Build
Here’s the full pipeline we’re wiring up:
- Continuous Integration :- Every push runs the test suite in GitHub Actions.
- Containerization :- A Docker image is built from your app.
- Registry push :- The image is pushed to GitHub Container Registry (GHCR).
- 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.
Prerequisites
Before we start, make sure you have:
- Node.js 20+ and npm installed locally
- Docker Desktop installed and running if containers are new to you, work through this Docker tutorial for beginners first, or spin up the free browser-based Docker playground to practice the commands without installing anything
- A GitHub account
- A Fly.io account (free tier is plenty)
- Basic Git familiarity keep this Git cheatsheet open in a tab if you need it
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;
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}`);
});
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");
});
});
Update the scripts block in your package.json:
"scripts": {
"start": "node server.js",
"test": "jest"
}
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"]
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
Test it locally:
docker build -t node-cicd-demo .
docker run -p 3000:3000 node-cicd-demo
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
Your code is now on GitHub. Nothing is automated yet. Let’s fix that.
Step 5: Write the GitHub Actions Workflow
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 }}
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
Follow the prompts (pick a region, skip the database). When it finishes, generate a deploy token:
flyctl tokens create deploy
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
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
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)