About this course
<p>If you've been building <a href="https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/">Node.js projects</a>, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks.</p>
<p>Maybe it's a different Node version, maybe an environment variable is missing, or maybe a system dependency doesn't match. You spend an hour debugging something that was never actually a code problem.</p>
<p>Docker fixes this at the root. With Docker, you stop shipping just code. The Node version, dependencies, and config all travel inside the container. Your laptop, a CI server, a production VM — it behaves the same on all of them. No more environment surprises.</p>
<p>In this tutorial, we'll go through all this step by step: a multi-stage Dockerfile, using Docker Compose with PostgreSQL for local development, and a GitHub Actions workflow that pushes a fresh image to Docker Hub on every merge to <code>main</code>.</p>
<p>The complete code for this tutorial is available on <a href="https://github.com/ziaongit/nodejs-docker-cicd">GitHub</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-sample-application">The Sample Application</a></p>
</li>
<li><p><a href="#heading-writing-the-dockerfile">Writing the Dockerfile</a></p>
</li>
<li><p><a href="#heading-the-dockerignore-file">The .dockerignore File</a></p>
</li>
<li><p><a href="#heading-the-gitignore-file">The .gitignore File</a></p>
</li>
<li><p><a href="#heading-build-and-test-the-image-locally">Build and Test the Image Locally</a></p>
</li>
<li><p><a href="#heading-docker-compose-for-local-development">Docker Compose for Local Development</a></p>
</li>
<li><p><a href="#heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</a></p>
</li>
<li><p><a href="#heading-deploying-the-image">Deploying the Image</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>Docker Desktop, which you can download at <a href="https://docs.docker.com/get-docker/">docs.docker.com/get-docker</a>. Windows users need WSL 2 before Docker starts. Open PowerShell as Administrator and run <code>wsl --install</code>. After the restart, Docker Desktop will install without issues.</p>
</li>
<li><p>A GitHub account</p>
</li>
<li><p>A Docker Hub account (free at <a href="https://hub.docker.com">hub.docker.com</a>)</p>
</li>
<li><p>Some Express.js experience helps, but isn't required</p>
</li>
</ul>
<h2 id="heading-the-sample-application">The Sample Application</h2>
<p>We're building a task management API with Express and PostgreSQL. Keep in mind the app is just a vehicle to teach you how this works. The Dockerfile and pipeline we set up here work the same way for any Node.js project.</p>
<p>Create the project:</p>
<pre><code class="language-bash">mkdir nodejs-docker-cicd && cd nodejs-docker-cicd
npm init -y
npm install express pg dotenv
npm install --save-dev nodemon
</code></pre>
<p>Create <code>src/index.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { Pool } = require('pg');
require('dotenv').config();
const app = express();
app.use(express.json());
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
});
// Create table on startup
pool.query(`
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
)
`).catch(console.error);
// Health check — required for Docker HEALTHCHECK and load balancers
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.get('/tasks', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC');
res.json(result.rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/tasks', async (req, res) => {
const { title } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
try {
const result = await pool.query(
'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
[title]
);
res.status(201).json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.patch('/tasks/:id', async (req, res) => {
const { id } = req.params;
const { completed } = req.body;
try {
const result = await pool.query(
'UPDATE tasks SET completed = $1 WHERE id = $2 RETURNING *',
[completed, id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Task not found' });
res.json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
</code></pre>
<p>Open <code>package.json</code> and update the <code>"scripts"</code> section:</p>
<pre><code class="language-json">"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
}
</code></pre>
<p><code>npm start</code> runs the app directly with Node. <code>npm run dev</code> uses nodemon so the server restarts automatically when you edit a file.</p>
<p>For running without Docker, create a <code>.env</code> file:</p>
<pre><code class="language-plaintext">DB_HOST=localhost
DB_PORT=5432
DB_NAME=tasksdb
DB_USER=postgres
DB_PASSWORD=yourpassword
PORT=3000
</code></pre>
<p>Notice that all database credentials come from environment variables rather than being hardcoded. Swap the variables, and the same image runs against your local database or a production one — no code changes needed. The <code>/health</code> endpoint is what Docker pings to know the app is actually handling requests.</p>
<h2 id="heading-writing-the-dockerfile">Writing the Dockerfile</h2>
<p>Before touching the Dockerfile, there are two terms you'll keep seeing. An <strong>image</strong> is a packaged, immutable version of your app — Node runtime, code, dependencies, everything together in one artifact. A <strong>container</strong> is a running instance of that image. One image, many containers, any machine.</p>
<p>Here's the Dockerfile we'll use:</p>
<pre><code class="language-dockerfile"># ── Stage 1: Install dependencies ──────────────────────────────────────────
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files first — Docker caches this layer separately.
# If you only change src code (not package.json), Docker skips npm ci on rebuild.
COPY package*.json ./
RUN npm ci
COPY . .
# ── Stage 2: Production image ───────────────────────────────────────────────
FROM node:18-alpine AS production
# Create a non-root user — running as root inside a container is a security risk
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodeuser -u 1001
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Copy only the source code from the builder stage (not node_modules or dev files)
COPY --from=builder /app/src ./src
RUN chown -R nodeuser:nodejs /app
USER nodeuser
EXPOSE 3000
# Docker will ping /health every 30s. If it fails 3 times, the container is marked unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "src/index.js"]
</code></pre>
<p>This is a multi-stage build. The first stage (<code>builder</code>) installs everything, including dev dependencies. The second stage (<code>production</code>) starts fresh and only copies what the app needs to run. Nodemon, test frameworks, and anything else dev-only never make it into the final image.</p>
<p>The size difference is real. A <code>node:18</code> Debian image is over 950MB. Switch to <code>node:18-alpine</code> and cut out the dev dependencies, and the final image lands around 150–200MB instead. A smaller image means faster pushes and faster deploys.</p>
<p><code>npm ci</code> instead of <code>npm install</code> is a deliberate choice for CI/CD. It reads exact versions from <code>package-lock.json</code> and fails hard if the lockfile doesn't match <code>package.json</code>. Every build on every machine installs the exact same versions — no surprises from a dependency that quietly updated overnight.</p>
<p>The <code>nodeuser</code> account exists because containers run as root by default. That's fine until something goes wrong. A non-root user means that an attacker who gets inside the container can't just do whatever they want.</p>
<h2 id="heading-the-dockerignore-file">The <code>.dockerignore</code> File</h2>
<p>Create <code>.dockerignore</code> before building:</p>
<pre><code class="language-plaintext">node_modules
npm-debug.log
.env
.git
.gitignore
README.md
Dockerfile
.dockerignore
</code></pre>
<p>The <code>node_modules</code> exclusion is the critical one. Your local modules were compiled for your operating system — macOS or Windows binaries won't work inside a Linux container. Excluding them means Docker installs fresh modules during the build, compiled for the correct platform. Without this exclusion, you'd either copy broken binaries into the image or waste time uploading hundreds of megabytes to the build context.</p>
<p>Never put <code>.env</code> in an image. Passwords, API keys, anything sensitive — those go in at runtime as environment variables, never inside the image itself.</p>
<h2 id="heading-the-gitignore-file">The <code>.gitignore</code> File</h2>
<p>One more thing before the first commit: a <code>.gitignore</code>. You don't want <code>node_modules</code> or <code>.env</code> tracked:</p>
<pre><code class="language-plaintext">node_modules/
.env
.env.local
npm-debug.log*
logs/
.DS_Store
Thumbs.db
.vscode/
.idea/
dist/
build/
</code></pre>
<h2 id="heading-build-and-test-the-image-locally">Build and Test the Image Locally</h2>
<p>Open Docker Desktop first and give it a moment. On Windows, you'll see a whale icon in the taskbar that animates while the engine is starting up. Once it goes still, you're good to run Docker commands. If you try to run Docker before the engine is up, you'll hit this:</p>
<pre><code class="language-plaintext">ERROR: Error response from daemon: Docker Desktop is unable to start
</code></pre>
<p>If that happens, quit Docker Desktop. Open PowerShell as Administrator, run <code>wsl --update</code>, and restart. Then go to Control Panel → Programs → Turn Windows features on or off. Both Hyper-V and Virtual Machine Platform need to be checked. After the restart, Docker Desktop should come up fine.</p>
<p>It's worth knowing about this error too:</p>
<pre><code class="language-plaintext">docker : The term 'docker' is not recognized as the name of a cmdlet, function,
script file, or operable program.
</code></pre>
<p>This means that Docker Desktop isn't running or isn't installed. Open it from the Start menu and wait.</p>
<p>Run the build:</p>
<pre><code class="language-bash">docker build -t nodejs-docker-cicd:latest .
</code></pre>
<p>The first time takes roughly 30 seconds since Docker has to pull <code>node:18-alpine</code> from the internet. Once that's cached, subsequent builds are much quicker. Both stages will scroll by:</p>
<pre><code class="language-plaintext">[+] Building 33.1s (17/17) FINISHED
=> [builder 1/5] FROM docker.io/library/node:18-alpine 20.9s
=> [builder 4/5] RUN npm ci 3.5s
=> [production 5/7] RUN npm ci --only=production 3.2s
=> [production 7/7] RUN chown -R nodeuser:nodejs /app 3.2s
=> exporting to image 1.5s
=> => naming to docker.io/library/nodejs-docker-cicd:latest 0.0s
</code></pre>
<p>When you see <code>(17/17) FINISHED</code> the image is built. Check the size:</p>
<pre><code class="language-bash">docker images nodejs-docker-cicd
</code></pre>
<pre><code class="language-plaintext">IMAGE ID DISK USAGE CONTENT SIZE
nodejs-docker-cicd:latest c9eed311d999 198MB 47.5MB
</code></pre>
<p><strong>CONTENT SIZE</strong> (47.5MB) is the compressed size that gets pushed to Docker Hub. <strong>DISK USAGE</strong> (198MB) is what it takes up on disk locally. Compare that to a <code>node:18</code> Debian image at 950MB+, and you can see why the Alpine base and multi-stage approach matter.</p>
<p>On subsequent builds, Docker reuses cached layers. Edit only your source files without touching <code>package.json</code> and the <code>npm ci</code> step gets skipped completely. That 33-second first build becomes 3 seconds.</p>
<h2 id="heading-docker-compose-for-local-development">Docker Compose for Local Development</h2>
<p>The app needs a database. Setting up PostgreSQL locally means every developer who clones the repo has to do it, too. Docker Compose handles this: one file defines both services, and one command starts them.</p>
<p>Create <code>docker-compose.yml</code>:</p>
<pre><code class="language-yaml">services:
app:
build:
context: .
target: production
ports:
- '3000:3000'
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: tasksdb
DB_USER: postgres
DB_PASSWORD: postgres
PORT: 3000
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: tasksdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
</code></pre>
<p>A few things worth pointing out. <code>DB_HOST</code> is set to <code>postgres</code>. That's the service name, not <code>localhost</code>. Containers on the same Docker network reach each other by service name. Put <code>localhost</code> there and the app tries to connect to itself.</p>
<p><code>depends_on</code> with <code>condition: service_healthy</code> holds the app back until Postgres actually passes its health check. Skip this and the app starts, tries to connect to a database that isn't ready yet, and crashes. The health check pings <code>pg_isready</code> every 5 seconds. Once it gets a green response, the app container starts.</p>
<p>The named volume <code>postgres_data</code> keeps your data alive between restarts. Run <code>docker compose down</code> and the data is still there next time. Add <code>--volumes</code> to wipe it clean.</p>
<p>Start both services:</p>
<pre><code class="language-bash">docker compose up --build
</code></pre>
<p>You'll see PostgreSQL initialize and then the app start. Once you see <code>Server running on port 3000</code> in the logs, the stack is up.</p>
<p>Open a second terminal to test — leave the compose logs running in the first one.</p>
<p><strong>Linux/macOS:</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Docker"}'
curl http://localhost:3000/tasks
curl http://localhost:3000/health
</code></pre>
<p><strong>Windows PowerShell:</strong> Typing <code>curl</code> in PowerShell runs <code>Invoke-WebRequest</code>, not actual curl. Run <code>curl.exe</code> instead. For JSON bodies, write to a file first:</p>
<pre><code class="language-powershell">'{"title": "Learn Docker"}' | Set-Content body.json
curl.exe -X POST http://localhost:3000/tasks -H "Content-Type: application/json" --data `@body.json
curl.exe http://localhost:3000/tasks
curl.exe http://localhost:3000/health
</code></pre>
<p>The backtick before <code>@body.json</code> is necessary. PowerShell would otherwise try to interpret <code>@</code> as a splatting operator rather than passing it to curl as a filename prefix.</p>
<p>You should see responses like these:</p>
<pre><code class="language-json"># POST /tasks
{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}
# GET /tasks
[{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}]
# GET /health
{"status":"ok","timestamp":"2026-07-09T22:11:44.700Z"}
</code></pre>
<p>The task hit PostgreSQL in one container and came back through the app. <code>Ctrl+C</code> in the compose terminal stops both.</p>
<h2 id="heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</h2>
<p>The image works locally, so it's time to stop doing this by hand.</p>
<h3 id="heading-step-1-create-a-docker-hub-access-token">Step 1: Create a Docker Hub Access Token</h3>
<p>Go to <a href="https://hub.docker.com">hub.docker.com</a> and then Account Settings → Security → New Access Token. Set permission to Read & Write, as read-only breaks the push. The token appears once, so copy it before closing the page.</p>
<p><strong>Security warning:</strong> Don't paste this token into a chat, email, or commit. If you expose it by accident, delete it immediately, then make a new one.</p>
<h3 id="heading-step-2-add-secrets-to-your-github-repository">Step 2: Add Secrets to Your GitHub Repository</h3>
<p>Head to Settings → Secrets and variables → Actions in your repo and add:</p>
<ul>
<li><p><code>DOCKERHUB_USERNAME</code> — your Docker Hub username</p>
</li>
<li><p><code>DOCKERHUB_TOKEN</code> — paste the token here, nowhere else</p>
</li>
</ul>
<p>If you ran into <code>Error: Username and password required</code>, the secrets either aren't saved yet or the names are typed wrong. Both are case-sensitive.</p>
<p>A Node 20 deprecation warning in the logs is normal. It comes from the Docker actions internally, not your code.</p>
<h3 id="heading-step-3-create-the-workflow-file">Step 3: Create the Workflow File</h3>
<p>Create <code>.github/workflows/docker-publish.yml</code>:</p>
<pre><code class="language-yaml">name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/nodejs-docker-cicd
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
target: production
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
</code></pre>
<p>The login step has <code>if: github.event_name != 'pull_request'</code>. This skips authentication on pull requests. PRs from forks don't have access to your secrets, so trying to log in would just fail. The build still runs on PRs to validate your Dockerfile, but the image isn't pushed.</p>
<p>The metadata action generates two tags on every merge to <code>main</code>: <code>latest</code> and a short commit SHA like <code>sha-a1b2c3d</code>. The SHA tag is what makes rollbacks practical. If <code>latest</code> breaks in production, you can pull any previous <code>sha-</code> tag and you're back to a known-good state in seconds.</p>
<p>The <code>cache-from/cache-to: type=gha</code> lines store Docker's layer cache in GitHub Actions' built-in cache. The first run builds everything from scratch. After that, unchanged layers are pulled from cache rather than rebuilt. On a typical Node.js app this brings build time from 2–3 minutes down to under 30 seconds.</p>
<h3 id="heading-push-and-watch-it-run">Push and Watch it Run</h3>
<pre><code class="language-bash">git add .
git commit -m "Add Docker configuration and GitHub Actions workflow"
git push origin main
</code></pre>
<p>Go to your repo's <strong>Actions</strong> tab. You'll see the workflow running in real time. Each step turns green as it completes:</p>
<pre><code class="language-plaintext">✅ Checkout code
✅ Set up Docker Buildx
✅ Log in to Docker Hub
✅ Extract metadata
✅ Build and push
</code></pre>
<p>Green across the board means your image is live on Docker Hub — two tags, <code>latest</code> and a commit SHA like <code>sha-a1b2c3d</code>. Every push to <code>main</code> from here builds and ships automatically.</p>
<h2 id="heading-deploying-the-image">Deploying the Image</h2>
<p>With your image on Docker Hub, you can deploy it to any infrastructure:</p>
<p><strong>Any VPS or server:</strong></p>
<pre><code class="language-bash">docker pull yourusername/nodejs-docker-cicd:latest
docker run -d -p 3000:3000 \
-e DB_HOST=your-db-host \
-e DB_NAME=tasksdb \
-e DB_USER=postgres \
-e DB_PASSWORD=yourpassword \
yourusername/nodejs-docker-cicd:latest
</code></pre>
<p><strong>Railway</strong> — Connect your Docker Hub image in the Railway dashboard and it deploys on the next push.</p>
<p><strong>Fly.io</strong> — Run <code>fly launch</code> pointing at your Dockerfile and Fly handles the rest.</p>
<p><strong>Render</strong> — Paste your Docker Hub image URL into the Render service settings.</p>
<p>Each push to <code>main</code> runs the workflow. New image goes to Docker Hub, platform picks it up — that's your deployment handled.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>What started as a local Node.js app now runs in a container. You get the same behavior on any machine, real PostgreSQL in development, and a pipeline that builds and ships to Docker Hub without you doing anything after the push.</p>
<p>The multi-stage build keeps the image lean — dev tools stay out, non-root user, health check baked in. Compose gets the full stack up with one command for anyone who clones the repo. The SHA tag on every GitHub Actions build means rolling back is just a matter of pulling an older tag.</p>
<p>These same patterns (multi-stage builds, Compose for local development, automated image publishing) are used across the industry for production Node.js deployments. Pick up these patterns once and they follow you to every project.</p>
<p>From here, you can extend the pipeline: drop a test step in before the build, or add multi-platform support if you're targeting ARM. Once Docker Compose starts feeling limiting in production, that's usually when Kubernetes enters the picture.</p>