A reliable deployment starts before code reaches your server. Tests should catch broken behavior, container images should identify exactly what they contain, and failed releases should leave you with a clear recovery path.

In this tutorial, you’ll build an Express API and a GitHub Actions pipeline that tests changes, publishes Docker images to GitHub Container Registry, and deploys them to a Linux server. The API exposes health and release information so you can verify which commit is actually running.

What you’ll build

Your project will include:

· An Express API with health and version endpoints. · Automated HTTP tests using Node.js’s built-in test runner. · A Docker image that runs as a non-root user. · Pull request checks that run without deployment credentials. · Private container images published to GitHub Container Registry, or GHCR. · Deployment over SSH using an exact image digest. · Health checks and automatic restoration of the previous image when a rollout fails.

The pipeline follows this path:

flowchart LR
  PR[Pull request] --> T[Checks and tests]
  M[Push to main] --> T
  T -->|main only| B[Build and publish image]
  B --> D[Deploy image digest]
  D --> H{Healthy?}
  H -->|Yes| S[Record successful release]
  H -->|No| R[Restore previous image]

This is a single-server deployment. Replacing its single API container can briefly interrupt traffic.

Prerequisites

You’ll need:

· Node.js 24 and npm. · Git and a Bash terminal, including WSL on Windows. · Docker and Docker Compose V2. · A GitHub repository with Actions enabled. · An x86-64 Linux server reachable over SSH from GitHub-hosted runners. · Docker Engine, Compose V2 version 2.20 or newer, and curl installed on the server. · An existing server account with SSH access and permission to run provisioning commands with sudo.

The examples use an SSH account named deploy and SSH port 22. Replace the username and YOUR_VPS_IP with your own values.

For an Ubuntu server, follow the official Docker Engine installation instructions and install the Compose plugin.

Project structure

psc-release-api/
├── .github/
│   └── workflows/
│       └── ci-cd.yml
├── scripts/
│   └── deploy.sh
├── src/
│   ├── app.js
│   └── server.js
├── test/
│   └── app.test.js
├── .dockerignore
├── .env.example
├── .gitignore
├── compose.yaml
├── compose.production.yaml
├── Dockerfile
├── package.json
└── package-lock.json

package-lock.json will be generated by npm. Commit it so local development, CI, and Docker install the same dependency versions.

Create the project and environment configuration

Create the directories:

mkdir -p psc-release-api/{src,test,scripts,.github/workflows}
cd psc-release-api

Create package.json:

{
  "name": "psc-release-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24 <25"
  },
  "scripts": {
    "start": "node --env-file-if-exists=.env src/server.js",
    "check": "node --check src/app.js && node --check src/server.js && node --check test/app.test.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^5.2.1"
  }
}

Node loads the local .env file directly, so this project does not need dotenv.

Create .env.example:

PORT=3000
SERVICE_NAME=psc-release-api

Create .gitignore:

node_modules/
.env
.env.*
!.env.example
coverage/

Install dependencies and create your local environment file:

npm install
cp .env.example .env

The example file belongs in Git. Your actual .env file stays local.

Build the release-status API

Create src/app.js:

import express from 'express';

export function createApp({
  serviceName = 'psc-release-api',
  version = 'development',
} = {}) {
  const app = express();

  app.disable('x-powered-by');

  app.use((_req, res, next) => {
    res.set('Cache-Control', 'no-store');
    next();
  });

  app.get('/healthz', (_req, res) => {
    res.json({ status: 'ok' });
  });

  app.get('/api/version', (_req, res) => {
    res.json({ service: serviceName, version });
  });

  app.use((_req, res) => {
    res.status(404).json({ error: 'Not found' });
  });

  return app;
}

These endpoints serve different operational purposes:

Endpoint Purpose GET /healthz Confirm that the HTTP service responds successfully. GET /api/version Identify the service and deployed Git commit.

Cache-Control: no-store prevents a cached response from showing an outdated release.

Create src/server.js:

import { createApp } from './app.js';

const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
  throw new Error('PORT must be an integer between 1 and 65535');
}

const app = createApp({
  serviceName: process.env.SERVICE_NAME,
  version: process.env.APP_VERSION,
});

const server = app.listen(port, '0.0.0.0', () => {
  console.log(`API listening on port ${port}`);
});

server.on('error', (error) => {
  console.error(error);
  process.exit(1);
});

function shutdown(signal) {
  console.log(`${signal}: closing HTTP server`);
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(1), 10_000).unref();
}

process.once('SIGTERM', () => shutdown('SIGTERM'));
process.once('SIGINT', () => shutdown('SIGINT'));

Separating application creation from server startup lets tests inject configuration and choose an available port.

The signal handlers let active requests finish during shutdown, with a ten-second limit.

Start the API:

npm start

From another terminal:

curl -fsS http://localhost:3000/healthz

Expected response:

{"status":"ok"}

Check the version:

curl -fsS http://localhost:3000/api/version

Expected response:

{"service":"psc-release-api","version":"development"}

Stop the process with Ctrl+C before starting Docker later.

Add automated HTTP tests

Create test/app.test.js:

import assert from 'node:assert/strict';
import { once } from 'node:events';
import { after, before, test } from 'node:test';

import { createApp } from '../src/app.js';

let server;
let baseUrl;

before(async () => {
  server = createApp({
    serviceName: 'test-api',
    version: 'abc123',
  }).listen(0, '127.0.0.1');

  await once(server, 'listening');
  baseUrl = `http://127.0.0.1:${server.address().port}`;
});

after(() => new Promise((resolve, reject) => {
  server.close((error) => error ? reject(error) : resolve());
}));

test('health endpoint returns an uncached success response', async () => {
  const response = await fetch(`${baseUrl}/healthz`);
  assert.equal(response.status, 200);
  assert.equal(response.headers.get('cache-control'), 'no-store');
  assert.deepEqual(await response.json(), { status: 'ok' });
});

test('version endpoint identifies the deployed release', async () => {
  const response = await fetch(`${baseUrl}/api/version`);
  assert.equal(response.status, 200);
  assert.deepEqual(await response.json(), {
    service: 'test-api',
    version: 'abc123',
  });
});

test('unknown routes return JSON with a 404 status', async () => {
  const response = await fetch(`${baseUrl}/missing`);
  assert.equal(response.status, 404);
  assert.deepEqual(await response.json(), {
    error: 'Not found',
  });
});

Port 0 asks the operating system to select an available port. This avoids collisions with other processes during CI.

Run syntax checks and tests:

npm run check
npm test

All three tests should pass. These tests exercise real HTTP responses, including status codes, headers, and JSON bodies.

Containerize the API

Create .dockerignore:

node_modules
.git
.github
.env
.env.*
coverage
test
scripts

This keeps local configuration and unnecessary files out of the build context.

Create Dockerfile:

FROM node:24-bookworm-slim AS dependencies

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

FROM node:24-bookworm-slim AS runtime

WORKDIR /app

ARG APP_VERSION=development
ENV NODE_ENV=production PORT=3000 APP_VERSION=${APP_VERSION}

COPY --from=dependencies --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src

USER node
EXPOSE 3000

HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:' + process.env.PORT + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

CMD ["node", "src/server.js"]

The dependency stage installs production packages from the lockfile. The runtime stage copies only what the service needs.

APP_VERSION is public release metadata. CI will set it to the full Git commit SHA. Keep it out of .env.example so runtime configuration does not replace the version embedded in the image.

Use Docker build arguments for metadata like this; supply credentials and application secrets at runtime.

Create compose.yaml for local use:

services:
  api:
    build: .
    environment:
      PORT: 3000
      SERVICE_NAME: ${SERVICE_NAME:-psc-release-api}
    ports:
      - "127.0.0.1:${PORT:-3000}:3000"
    init: true

Compose uses .env to resolve PORTand{PORT} and {SERVICE_NAME}. The container listens on port 3000; PORT in the local .env controls the published host port.

Start the container and wait for its health check:

docker compose up -d --build --wait --wait-timeout 60

Test it:

curl -fsS http://localhost:3000/healthz
curl -fsS http://localhost:3000/api/version
curl -i http://localhost:3000/missing

The last request should return HTTP 404 with a JSON error.

Inspect the container and logs:

docker compose ps
docker compose logs --tail=50 api

Stop it when finished:

docker compose down

Define production deployment and recovery

Create compose.production.yaml:

services:
  api:
    image: ${IMAGE_REF:?Set IMAGE_REF to the published image digest}
    environment:
      NODE_ENV: production
      PORT: 3000
      SERVICE_NAME: ${SERVICE_NAME:-psc-release-api}
    ports:
      - "127.0.0.1:3000:3000"
    restart: unless-stopped
    init: true
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Production pulls a published image instead of building source code on the server.

The host port binds to loopback, keeping the API accessible through SSH or a local reverse proxy. The container has a read-only filesystem, writable temporary storage, and bounded logs.

Create scripts/deploy.sh:

#!/usr/bin/env bash
set -Eeuo pipefail

cd /opt/psc-release-api

image="${1:?Usage: deploy.sh IMAGE_REF}"
export IMAGE_REF="$image"

compose=(
  docker compose
  -p psc-release-api
  --env-file .env
  -f compose.production.yaml
)

previous="$(cat current-image.txt 2>/dev/null || true)"

# A failed pull leaves the running release untouched.
"${compose[@]}" pull api

if "${compose[@]}" up -d --wait --wait-timeout 60 &&
   curl --fail --silent --show-error \
     http://127.0.0.1:3000/api/version; then
  printf '%s\n' "$image" > current-image.txt.tmp
  mv current-image.txt.tmp current-image.txt
  printf '\n'
  exit 0
fi

"${compose[@]}" logs --tail=100 api || true

if [[ -n "$previous" ]]; then
  echo "Deployment failed; restoring the previous image."
  export IMAGE_REF="$previous"
  "${compose[@]}" up -d --wait --wait-timeout 60
else
  echo "First deployment failed; no previous image exists." >&2
fi

exit 1

The script:

· Pulls the candidate image before replacing the running container. · Waits for Docker health checks and checks the version endpoint. · Records the successful image reference. · Restores the previous image if the rollout fails. · Returns a failure status even when recovery succeeds.

The first deployment has no previous image to restore.

The production Compose file is provisioned separately and stays on the server. Rollback restores the previous image using that configuration. Update ports, volumes, or service topology as a separate configuration change.

Prepare the server

Create a deployment key

On your local machine:

ssh-keygen -t ed25519 \
  -C "psc-github-actions" \
  -f "$HOME/.ssh/psc_actions" \
  -N ""

Install its public key using your existing server access:

ssh-copy-id -i "$HOME/.ssh/psc_actions.pub" deploy@YOUR_VPS_IP

Keep the private key outside your project directory.

Prepare the deployment directory

Connect to the server:

ssh -i "$HOME/.ssh/psc_actions" deploy@YOUR_VPS_IP

Run:

sudo usermod -aG docker deploy
sudo install -d \
  -o deploy \
  -g deploy \
  -m 750 \
  /opt/psc-release-api

Docker group membership grants control equivalent to root. Use a dedicated deployment account on a server you administer.

Disconnect and reconnect so the new group membership applies.

Verify Docker access:

docker version
docker compose version

Copy the production configuration

From your local project directory:

scp -i "$HOME/.ssh/psc_actions" \
  compose.production.yaml \
  .env.example \
  deploy@YOUR_VPS_IP:/opt/psc-release-api/

On the server:

cd /opt/psc-release-api
cp .env.example .env
chmod 600 .env

Edit SERVICE_NAME if needed. Production uses a fixed port of 3000.

Authenticate the server with GHCR

GHCR packages are private by default. Create a GitHub personal access token classic with read:packages, using an account that can read the package. Authorize organization SSO if your organization requires it.

On the server, logged in as deploy:

read -r -s -p "GHCR read token: " GHCR_TOKEN
printf '\n'
printf '%s' "$GHCR_TOKEN" | \
  docker login ghcr.io \
    --username YOUR_GITHUB_USERNAME \
    --password-stdin
unset GHCR_TOKEN

Docker stores the registry credential for this server account. The workflow will use GitHub’s built-in token to publish images.

Verify the SSH host key

From your local machine:

ssh-keyscan -t ed25519 YOUR_VPS_IP > "$HOME/.ssh/psc_known_hosts"
ssh-keygen -lf "$HOME/.ssh/psc_known_hosts"

Compare that fingerprint with the server’s fingerprint obtained through your trusted server console:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

ssh-keyscan discovers a key; the fingerprint comparison establishes trust. You’ll store the verified host-key line in GitHub next.

Add the GitHub Actions workflow

Create .github/workflows/ci-cd.yml:

name: CI/CD

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '24'
          cache: npm

      - run: npm ci
      - run: npm run check
      - run: npm test

  publish:
    needs: test
    if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    permissions:
      contents: read
      packages: write
    outputs:
      image_ref: ${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - name: Set lowercase image name
        id: image
        run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"

      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

      - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        id: build
        with:
          context: .
          push: true
          platforms: linux/amd64
          tags: ${{ steps.image.outputs.name }}:${{ github.sha }}
          build-args: APP_VERSION=${{ github.sha }}
          labels: |
            org.opencontainers.image.source=https://github.com/${{ github.repository }}
            org.opencontainers.image.revision=${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Record the published image
        env:
          IMAGE_REF: ${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }}
        run: echo "$IMAGE_REF" >> "$GITHUB_STEP_SUMMARY"

  deploy:
    needs: publish
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    environment: production
    concurrency:
      group: psc-release-api-production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - name: Check this is still the latest main commit
        id: current
        run: |
          git fetch origin main --depth=1

          if [[ "$(git rev-parse origin/main)" == "$GITHUB_SHA" ]]; then
            echo "deploy=true" >> "$GITHUB_OUTPUT"
          else
            echo "Skipping a superseded commit."
            echo "deploy=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Deploy and verify health
        if: steps.current.outputs.deploy == 'true'
        env:
          VPS_HOST: ${{ secrets.VPS_HOST }}
          VPS_USER: ${{ secrets.VPS_USER }}
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
          IMAGE_REF: ${{ needs.publish.outputs.image_ref }}
        run: |
          install -d -m 700 "$HOME/.ssh"

          printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/deploy_key"
          printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"

          chmod 600 "$HOME/.ssh/deploy_key" "$HOME/.ssh/known_hosts"
          trap 'rm -f "$HOME/.ssh/deploy_key"' EXIT

          ssh_options=(
            -i "$HOME/.ssh/deploy_key"
            -o BatchMode=yes
            -o IdentitiesOnly=yes
            -o StrictHostKeyChecking=yes
          )

          scp "${ssh_options[@]}" scripts/deploy.sh \
            "$VPS_USER@$VPS_HOST:/opt/psc-release-api/deploy.sh"

          ssh "${ssh_options[@]}" "$VPS_USER@$VPS_HOST" \
            "bash /opt/psc-release-api/deploy.sh '$IMAGE_REF'"

Understand the three jobs

test runs for pull requests, pushes to main, and manual workflow runs. npm ci installs the lockfile exactly. The npm cache speeds downloads while dependencies are still installed afresh.

publish runs only for main after tests pass. It builds an AMD64 image, embeds the commit SHA, and publishes a SHA-tagged image to GHCR.

deploy receives the published digest, verifies the commit is still current, and runs the server’s deployment script.

The deployment reference looks like this:

ghcr.io/your-user/psc-release-api@sha256:...

A digest identifies exact image content. A tag can be reassigned, including when a workflow is rerun.

The concurrency group prevents overlapping deployment jobs. cancel-in-progress: false lets an active deployment finish. The latest-commit check skips older runs that have already been superseded.

Actions are pinned to full commit SHAs. Review and update those pins as new versions become available.

Configure GitHub secrets and branch protection

Create your GitHub repository if you haven’t already.

Under Settings → Environments, create an environment named production. Restrict deployment branches to main, and add these environment secrets:

Secret Value VPS_HOST Your server’s public IP or hostname, matching the verified host-key entry. VPS_USER Your deployment username, such as deploy. SSH_PRIVATE_KEY The complete contents of your local psc_actions private key. SSH_KNOWN_HOSTS The verified contents of psc_known_hosts.

Preserve the private key’s opening line, closing line, and newlines.

You don’t need to create a GITHUB_TOKEN secret. GitHub provides it automatically, and only the publishing job receives packages: write.

After the first successful workflow run, configure a branch protection rule or ruleset for main that requires the test check before merging pull requests.

Push and verify the deployment

Initialize Git and push the project:

git init -b main
git add .
git commit -m "Add release API and GitHub Actions CI/CD"
git remote add origin git@github.com:YOUR_USER/psc-release-api.git
git push -u origin main

Open the repository’s Actions tab. You should see:

· Tests passing. · A container image being published. · Deployment waiting for a healthy container. · The version endpoint returning the deployed commit SHA.

The publishing job’s summary contains the exact image reference. Keep earlier successful references available for manual recovery.

Test production through an SSH tunnel

The API binds to the server’s loopback interface. Open a tunnel from your local machine:

ssh -i "$HOME/.ssh/psc_actions" \
  -N \
  -L 8080:127.0.0.1:3000 \
  deploy@YOUR_VPS_IP

In another terminal:

curl -fsS http://localhost:8080/healthz
curl -fsS http://localhost:8080/api/version

The version response should contain the deployed commit:

{
  "service": "psc-release-api",
  "version": "YOUR_40_CHARACTER_COMMIT_SHA"
}

Compare it with:

git rev-parse HEAD

Inspect production logs

On the server:

cd /opt/psc-release-api
export IMAGE_REF="$(cat current-image.txt)"
docker compose -p psc-release-api \
  --env-file .env \
  -f compose.production.yaml \
  ps
docker compose -p psc-release-api \
  --env-file .env \
  -f compose.production.yaml \
  logs --tail=100 api

Verify the test gate

Temporarily change the health test’s expected status from ok to broken and run:

npm test

The test should fail. Restore the assertion afterward.

The same failure in a pull request fails its check. On main, a failed test prevents the publishing and deployment jobs from running.

Perform a manual rollback

Copy a digest from an earlier successful publishing job’s summary. When no deployment is in progress, run on the server:

bash /opt/psc-release-api/deploy.sh \
  'ghcr.io/YOUR_USER/psc-release-api@sha256:PREVIOUS_IMAGE_DIGEST'

Use the lowercase image path recorded by the workflow.

Design decisions

Decision Why it matters Real HTTP tests Verify the behavior clients and health checks depend on. npm ci and a committed lockfile Install consistent JavaScript dependencies across environments. Separate testing, publishing, and deployment Make failures easier to locate and keep credentials scoped to their jobs. Publishing only from main Keep pull request checks independent of production access. Digest-based deployment Deploy the exact image produced by the publishing job. Commit SHA embedded in the image Verify the running release through /api/version. Verified SSH host keys Confirm the deployment connects to the intended server. Health-gated rollout Record success only after the service responds. Previous-image recovery Restore the last working image when a rollout fails. Loopback-only production port Keep access private until you configure a reverse proxy.

Common issues and troubleshooting

npm ci reports a missing or outdated lockfile

Generate or update it locally:

npm install
git add package.json package-lock.json

Commit both files together. CI should use npm ci.

Docker reports port is already allocated

A local Node process or another container may already use port 3000.

Stop it, or set a different local host port in .env:

PORT=3001
SERVICE_NAME=psc-release-api

Then recreate the local service:

docker compose up -d --build --wait

The container still listens internally on port 3000.

GHCR publishing returns a permission error

Confirm the publishing job has packages: write.

For an existing package, check its Actions access settings and repository association. Organization policies can also restrict package publishing.

The server cannot pull the private image

Run docker login ghcr.io as the same user that performs deployment.

Check that the token has read:packages, its account can read the package, and any required organization SSO authorization is active.

SSH returns Permission denied (publickey)

Check that:

· VPS_USER matches the account with the installed public key. · SSH_PRIVATE_KEY contains the complete private key. · The server’s .ssh directory has permission 700. · Its authorized_keys file has permission 600.

Test the same key locally:

ssh -i "$HOME/.ssh/psc_actions" deploy@YOUR_VPS_IP

SSH reports a host-key mismatch

Check whether the server was rebuilt or its address changed. Verify the current fingerprint through the trusted server console, then update SSH_KNOWN_HOSTS.

Keep strict host-key checking enabled.

Deployment fails its health check

Read the failed container logs in the Actions deployment output.

Typical causes include startup errors, incorrect container ports, or application changes that require writable storage.

A recovered deployment still appears as failed in Actions so the broken release remains visible.

An ARM server reports exec format error

The workflow publishes linux/amd64.

For an ARM server, change platforms to linux/arm64, or publish both:

platforms: linux/amd64,linux/arm64

The deployed API is unreachable from a browser

Production binds to 127.0.0.1, so direct public access is unavailable. Use the SSH tunnel, or place Caddy or Nginx in front of the service with HTTPS.

What you learned

You built a pipeline that checks code before publishing, produces traceable container images, and verifies service health during deployment.

You also separated application code from server configuration, scoped deployment credentials, verified SSH identity, and added a recovery path using the previous image.

The health and version endpoints give you a practical way to confirm both service availability and the release currently running.

Possible next steps

· Add Caddy or Nginx for public HTTPS access. · Create a staging environment using a separate server or Compose project. · Add container smoke tests before production deployment. · Configure Dependabot for npm dependencies and GitHub Actions pins. · Pin Docker base images by digest and automate reviewed updates. · Add readiness checks when the API gains databases or other dependencies. · Send application logs and deployment results to your monitoring system. · Move to rolling or blue-green deployments when brief interruptions become unacceptable.