Jiaxi Liu (Jesse)

UNSW Master of IT · Open to Software Development Roles

Full-stack & Backend Development · API Integration · Automation · Exploring AI Agents

Back to Blog

Git, GitHub Actions, Shell, and Docker: From Local Development to CI/CD and Containers

Reliable delivery requires knowing what changed, what was checked, which artifact was released, and how to recover. Git, Shell, GitHub Actions, and Docker each handle part of that process. Connecting them makes it possible to locate the failure behind “it works locally.”

This article follows an independent practice project called delivery-demo. TypeScript provides health and version endpoints, and PostgreSQL verifies dependency connectivity. The focus is delivery, not a complete order application. These examples are not this portfolio's deployment configuration; use a separate practice directory.

Edit → Local checks → Git commit → PR checks
                                    ↓
Merge main → Verify again → Build and push image → Record digest
                                                    ↓
                                Deploy → Health checks → Observe or roll back

1. Define the delivery contract and project structure

1.1 What each stage produces

Stage Input Output and failure boundary
Development Source and dependency declarations Reviewable changes; fix failed local checks
CI A specific commit and lockfile Results and test logs; failure blocks publishing
Build The same commit, base image, and dependencies Runnable image; missing outputs or dependencies must surface
Publish Built image Registry digest and release record
Deploy A digest and environment configuration Running instance; dependency or health failures need handling
Rollback Previous digest and compatible schema Restore the application version, not automatically the data

The practice runtime is Node.js 24, shared across local development, CI, and the image. Matching a major version is not complete reproducibility: a release process should also manage exact patch versions or image digests and upgrades. See Node.js releases and support.

1.2 How the files fit together

 delivery-demo/
 ├─ package.json              # scripts and dependencies
 ├─ package-lock.json         # generated once, then committed
 ├─ tsconfig.json
 ├─ eslint.config.mjs
 ├─ src/readiness.ts
 ├─ src/server.ts
 ├─ tests/unit.test.mjs
 ├─ tests/integration.test.mjs
 ├─ db/migrate.mjs
 ├─ scripts/check.sh
 ├─ Dockerfile
 ├─ .dockerignore
 ├─ .env.example
 ├─ compose.yaml
 └─ .github/workflows/ci.yml

src compiles into dist. Unit tests load compiled functions, integration tests use PostgreSQL, npm run migrate prepares a practice table, and npm start runs dist/server.js. Establish these contracts before adding automation; a command in YAML is useless if its corresponding project script does not exist.

2. Git: understand the change, not just the command

2.1 Working tree, index, commits, and branches

The working tree contains current files. The index describes the next commit. A commit records a snapshot and parent relationships. A branch names a commit, and HEAD usually names the current branch. origin/main reflects the last fetched remote state, not a live query of the server.

In a practice repository with an existing main branch, begin a change like this:

git status --short
git switch -c feature/readiness
git diff
git add -p
git diff --cached
git commit -m "feat: add database readiness check"
git push -u origin feature/readiness

git add -p is useful for selecting changes to tracked files; explicitly add new files with git add path/to/file. Reviewing staged differences helps avoid mixing debug files, credentials, or unrelated work into the commit.

Editing after committing does not alter the old commit. Staging is not a permanent backup either; save important work through a commit or another explicit mechanism.

2.2 What fetch, merge, and rebase change

git fetch origin updates your knowledge of remote commits without automatically integrating them into your current branch. git pull also integrates history according to configuration or arguments. Separating these operations makes them easier to reason about.

Suppose main gains C while your feature starts from B and adds D:

Before:
A──B──C       main
   └──D       feature
 
Merge:
A──B──C──M    feature
   └──D──┘
 
Rebase:
A──B──C──D'   feature

Merge can preserve a divergence with a merge commit, but can also fast-forward without creating a new node. Rebase reapplies changes and creates a new commit identity, D'. Linear history can help on an unshared personal branch; rewriting history that others depend on requires coordination. See Git rebase.

git fetch origin
git rebase origin/main
# Resolve conflicts, then stage only resolved files.
git add src/server.ts
git rebase --continue
# Alternative while the rebase is still in progress:
# git rebase --abort

Removing conflict markers is not the whole resolution. Understand both intentions, inspect the result, and rerun checks. If an already-pushed personal branch must be rewritten, agree on the workflow before considering --force-with-lease. Its protection is stronger than a blind overwrite, not permission to ignore collaborators.

2.3 Identify the layer you intend to undo

Goal Common operation Effect
Unstage while retaining edits git restore --staged path Changes the index
Discard unstaged edits to a file git restore path Loses that working-tree difference; preserve needed content first
Undo a local commit but keep it staged git reset --soft HEAD~1 Moves the branch, retaining index and working files
Undo a local commit and unstage it git reset --mixed HEAD~1 Resets the index, retaining working files
Undo a shared ordinary commit git revert <commit> Adds a reversing commit without deleting history

reset --hard additionally discards tracked changes and can remove untracked paths obstructing restoration. It is not a generic first step in troubleshooting. Path-based reset forms also differ from branch-moving forms. See Git reset and Git restore.

If you committed on the wrong branch, first retain the commit on a branch before choosing cherry-pick, revert, or another correction. After accidentally moving a branch, inspect git reflog and create a recovery branch at the appropriate commit. Reflogs are local and expire; they do not guarantee recovery of never-committed or never-staged content. See Git reflog.

3. A minimal service gives the automation real inputs

3.1 Dependencies, compilation, and static checks

Save the following as package.json. Ranges bootstrap the practice project; commit the lockfile produced by the initial install, then use npm ci for locked installations.

{
  "name": "delivery-demo",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=24 <25" },
  "scripts": {
    "lint": "eslint src",
    "typecheck": "tsc --noEmit",
    "build": "tsc",
    "test": "node --test tests/unit.test.mjs",
    "test:integration": "node --test tests/integration.test.mjs",
    "migrate": "node db/migrate.mjs",
    "start": "node dist/server.js"
  },
  "dependencies": { "pg": "^8.13.0" },
  "devDependencies": {
    "@eslint/js": "^9.0.0",
    "@types/node": "^24.0.0",
    "@types/pg": "^8.11.0",
    "eslint": "^9.0.0",
    "typescript": "^5.9.0",
    "typescript-eslint": "^8.0.0"
  }
}

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "noEmitOnError": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

eslint.config.mjs:

import js from "@eslint/js";
import tseslint from "typescript-eslint";
 
export default tseslint.config(
  { ignores: ["dist/**", "node_modules/**"] },
  js.configs.recommended,
  ...tseslint.configs.recommended,
);

Lint checks rules, typecheck checks types, build emits artifacts, and tests examine behavior. They overlap, but success in one does not establish success in the others.

3.2 Readiness logic and the HTTP entry point

src/readiness.ts translates probe outcomes into a stable response without exposing database exception details:

export async function readiness(probe: () => Promise<unknown>) {
  try {
    await probe();
    return { status: 200, body: { status: "ready" } };
  } catch {
    return { status: 503, body: { status: "unavailable" } };
  }
}

src/server.ts uses pg's PGHOST, PGPORT, PGUSER, PGPASSWORD, and PGDATABASE environment variables. Connection and query timeouts bound failures:

import { createServer } from "node:http";
import pg from "pg";
import { readiness } from "./readiness.js";
 
const pool = new pg.Pool({
  connectionTimeoutMillis: 2000,
  query_timeout: 2000,
});
pool.on("error", () => console.error("database pool error"));
 
const server = createServer(async (req, res) => {
  res.setHeader("Content-Type", "application/json");
  if (req.method !== "GET") {
    res.writeHead(405, { Allow: "GET" }).end();
    return;
  }
  if (req.url === "/healthz") {
    res.end(JSON.stringify({ status: "ok" }));
    return;
  }
  if (req.url === "/version") {
    res.end(JSON.stringify({ revision: process.env.APP_REVISION ?? "dev" }));
    return;
  }
  if (req.url === "/readyz") {
    const result = await readiness(() =>
      pool.query("SELECT id FROM service_checks LIMIT 0"),
    );
    res.statusCode = result.status;
    res.end(JSON.stringify(result.body));
    return;
  }
  res.writeHead(404).end(JSON.stringify({ error: "not_found" }));
});
 
const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
  throw new Error("Invalid PORT");
}
server.listen(port, process.env.HOST ?? "0.0.0.0");
 
let stopping = false;
function shutdown() {
  if (stopping) return;
  stopping = true;
  const deadline = setTimeout(() => process.exit(1), 10_000);
  deadline.unref();
  server.close(() => {
    void pool.end().then(() => {
      clearTimeout(deadline);
    }).catch(() => process.exit(1));
  });
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

/healthz checks that the HTTP process responds, /readyz also checks database connectivity and the practice table, and /version identifies the build commit. These are not complete business tests: a readable database does not establish every write permission, capacity requirement, or operation. Temporary database unavailability should fail readiness without necessarily causing repeated process restarts.

3.3 Migration and tests

db/migrate.mjs creates the practice table. Real applications need versioned migrations, concurrency coordination, and recovery. IF NOT EXISTS does not repair an existing table with the wrong shape.

import pg from "pg";
 
const pool = new pg.Pool({ connectionTimeoutMillis: 2000 });
try {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS service_checks (
      id UUID PRIMARY KEY,
      note TEXT NOT NULL
    )
  `);
} finally {
  await pool.end();
}

tests/unit.test.mjs needs no database and checks successful and failed probes:

import test from "node:test";
import assert from "node:assert/strict";
import { readiness } from "../dist/readiness.js";
 
test("successful probe is ready", async () => {
  const result = await readiness(async () => undefined);
  assert.equal(result.status, 200);
  assert.deepEqual(result.body, { status: "ready" });
});
 
test("failed probe does not expose internal errors", async () => {
  const result = await readiness(async () => {
    throw new Error("private database details");
  });
  assert.equal(result.status, 503);
  assert.deepEqual(result.body, { status: "unavailable" });
});

tests/integration.test.mjs connects to a real practice database, writes and reads through one transaction on one connection, then rolls back:

import test from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import pg from "pg";
 
test("database round trip", async () => {
  const pool = new pg.Pool({ connectionTimeoutMillis: 2000 });
  let client;
  try {
    client = await pool.connect();
    await client.query("BEGIN");
    const id = randomUUID();
    await client.query(
      "INSERT INTO service_checks (id, note) VALUES ($1, $2)",
      [id, "integration-check"],
    );
    const result = await client.query(
      "SELECT note FROM service_checks WHERE id = $1", [id],
    );
    assert.equal(result.rows[0].note, "integration-check");
  } finally {
    try {
      if (client) await client.query("ROLLBACK");
    } finally {
      client?.release();
      await pool.end();
    }
  }
});

Do not issue BEGIN on one connection and let the pool choose unrelated connections for subsequent statements. Connectivity, schema correctness, and business correctness are also separate verification layers.

4. Bash: propagate failures to the caller

4.1 Standard streams and redirection order

Processes normally have standard input 0, output 1, and error 2. A pipe connects standard output by default; standard error is not automatically included.

node dist/server.js > app.log 2> error.log
node dist/server.js >> app.log 2>&1

The first command records the streams separately. The second points stderr at the current stdout destination, appending both to app.log. > file 2>&1 and 2>&1 > file differ because order matters; the latter does not put everything in the file. These are alternative launch commands, not instructions to run two servers together.

4.2 Why a pipeline can hide a failing test

bash -c 'false | tee /dev/null; printf "status=%s\n" "$?"'
bash -o pipefail -c 'false | tee /dev/null; printf "status=%s\n" "$?"'

The first prints 0 and the second 1. A pipeline normally takes its result from the last command; pipefail lets an earlier failure affect the result. Successfully saving logs with tee does not prove that tests passed.

set -e has exceptions in contexts such as conditions, parts of AND/OR lists, and pipelines. It is not a complete error-handling strategy. set -u helps catch undefined variables, while optional values still need explicit defaults such as ${NAME:-default}. These examples use Bash and are not guaranteed to run with /bin/sh.

4.3 One check script for local development and CI

Save this as scripts/check.sh and run bash scripts/check.sh:

#!/usr/bin/env bash
set -euo pipefail
 
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd -- "$project_dir"
mkdir -p reports
 
trap 'code=$?; printf "check exit=%s\n" "$code" >&2' EXIT
 
npm run lint 2>&1 | tee reports/lint.log
npm run typecheck 2>&1 | tee reports/typecheck.log
npm run build 2>&1 | tee reports/build.log
npm test 2>&1 | tee reports/unit.log
 
if [[ "${RUN_INTEGRATION:-0}" == "1" ]]; then
  npm run test:integration 2>&1 | tee reports/integration.log
fi

The script resolves its project directory so callers can start elsewhere. Quoting keeps paths with spaces as single arguments. Building precedes tests because tests import dist. Dependency installation and database migration remain explicit caller responsibilities.

The EXIT trap reports the result without turning failure into success. Traps can also clean up temporary files or background processes, but only resources owned by the script. Do not append || true to critical tests merely to keep a pipeline green.

Some nonzero codes represent expected branches: grep uses 1 for no match, which differs from a file-reading error. Handle those cases deliberately. Logs may contain sensitive values; review what is uploaded and do not enable set -x over credentials or arbitrary environment values.

5. Git hooks: earlier feedback, not a replacement for CI

A pre-commit hook can check staged content, commit-msg can validate messages, and pre-push can run suitable tests. Fast checks support frequent commits; starting a full integration environment on every commit can undermine that feedback loop.

Custom hooks can live in a tracked .githooks directory. First create .githooks/pre-commit to check whitespace errors in staged differences:

#!/usr/bin/env bash
set -euo pipefail
git diff --cached --check

Each developer then installs the configuration:

git config core.hooksPath .githooks
chmod +x .githooks/pre-commit

Cloning does not automatically enable that configuration for everyone. Executable permissions, interpreters, and platforms need attention. Hooks have different bypass mechanisms, so local checks are not an unavoidable merge gate. See Git hooks.

Also ask what was checked. Linting the working tree may validate an unstaged fix while the staged version remains broken. Verify the specified commit on the server and use required checks or repository rules to govern merging.

6. GitHub Actions: which commit is checked, and where does failure stop?

6.1 Workflows, jobs, steps, and runners

A workflow defines triggers and jobs. A job runs steps on a runner, using actions or commands. Independent jobs can run concurrently; needs declares dependencies. Steps within a job normally share its working directory, but another job does not automatically inherit those files.

This example verifies PRs and pushes to main, while allowing image publication only for main push events. A PR check may test GitHub's prepared merge commit rather than the eventual main commit, so validating main again is meaningful.

6.2 A workflow with a database, failure logs, and a publish gate

Save this as .github/workflows/ci.yml. Commit package-lock.json first and protect main through repository rules. The examples use v7 major tags from the GitHub.com actions documentation. For a real deployment, review and pin complete commit SHAs, checking runner and GHES compatibility. See checkout, setup-node, and upload-artifact.

name: CI
 
on:
  pull_request:
  push:
    branches: [main]
  workflow_dispatch:
 
permissions:
  contents: read
 
concurrency:
  group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
jobs:
  verify:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: demo
          POSTGRES_PASSWORD: ci-only-password
          POSTGRES_DB: delivery
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U demo -d delivery"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 10
    env:
      PGHOST: 127.0.0.1
      PGPORT: "5432"
      PGUSER: demo
      PGPASSWORD: ci-only-password
      PGDATABASE: delivery
      RUN_INTEGRATION: "1"
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: actions/setup-node@v7
        with:
          node-version: "24"
          cache: npm
          cache-dependency-path: package-lock.json
      - run: npm ci
      - run: npm run migrate
      - run: bash scripts/check.sh
      - name: Preserve check logs
        if: ${{ always() }}
        uses: actions/upload-artifact@v7
        with:
          name: check-logs-${{ github.run_id }}-${{ github.run_attempt }}
          path: reports/
          if-no-files-found: ignore
          retention-days: 7
 
  image:
    needs: verify
    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - name: Build, smoke-check and push
        shell: bash
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_ACTOR: ${{ github.actor }}
          REPOSITORY: ${{ github.repository }}
          REVISION: ${{ github.sha }}
        run: |
          set -euo pipefail
          image="ghcr.io/${REPOSITORY,,}:${REVISION}"
          docker build --build-arg "REVISION=$REVISION" -t "$image" .
          docker run --rm "$image" node --input-type=module -e \
            'await import("pg"); await import("./dist/readiness.js");'
          printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GH_ACTOR" --password-stdin
          trap 'docker logout ghcr.io >/dev/null 2>&1 || true' EXIT
          docker push "$image"
          mkdir -p reports
          docker image inspect --format '{{index .RepoDigests 0}}' "$image" > reports/image.txt
          grep -Eq '^ghcr.io/.+@sha256:[0-9a-f]{64}$' reports/image.txt
          printf '%s\n' "$REVISION" > reports/commit.txt
      - uses: actions/upload-artifact@v7
        with:
          name: release-${{ github.sha }}-${{ github.run_attempt }}
          path: reports/
          retention-days: 30

The password belongs only to an ephemeral CI database. npm runs directly on the runner and connects through the mapped localhost port. A job running inside a container would normally connect through the postgres service name instead. Do not mix these network models. See GitHub PostgreSQL service containers.

needs: verify blocks publication after failed verification. The image job's import check establishes that runtime modules and artifacts load; it is not an HTTP or database end-to-end test. Manual dispatch only verifies in this example and does not publish. Scheduled workflows can add periodic checks but need explicit expectations about time zone, branch, and possible execution delay.

6.3 Cache, artifacts, and environment boundaries

setup-node's npm cache mainly reuses downloaded content and does not replace npm ci. A cache miss should still permit success. Artifacts preserve outputs of a particular run, such as logs, reports, and release records. See GitHub caching and artifacts.

An export inside one run step does not automatically affect later independent steps. Use GITHUB_ENV where appropriate across steps, and outputs or artifacts across jobs. Transfer deliberately selected data rather than archiving the entire environment.

The example cancels older PR checks to avoid wasted work but does not automatically interrupt a publication in progress. Deployments usually need their own serialization, and concurrency queues should not be assumed to retain every historical run.

6.4 PR code is not a trusted release program

External PRs can modify scripts and dependency installation behavior. Do not expose production credentials while testing them or casually execute them on shared runners with internal access. Switching to pull_request_target to obtain secrets and then executing untrusted PR code is not a safe shortcut.

Minimize GITHUB_TOKEN permissions and grant packages:write only to the trusted publishing job. Do not interpolate values such as titles or branch names directly into shell programs; pass them through environment variables and quote correctly. Cloud deployments can use OIDC-based short-lived identity with repository, branch, and environment restrictions. See GitHub secure use.

7. Docker: separate build requirements from runtime requirements

7.1 Images, containers, layers, and build context

An image describes filesystem layers and runtime configuration. A container is an instance that can be running or stopped. Writes to its writable layer do not modify the image; data that must outlive container removal needs explicit external storage.

The dot in docker build . identifies the build context. COPY selects files from that context rather than reading arbitrary host paths. Source changes can invalidate later build steps, which is why dependency manifests are commonly copied before source.

7.2 A multi-stage Dockerfile

Save this as Dockerfile. The build stage installs development dependencies, compiles, and runs unit tests. The runtime stage receives compiled output, the migration script, and production dependencies only.

# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
COPY tests/unit.test.mjs ./tests/unit.test.mjs
RUN npm run build && npm test
 
FROM node:24-bookworm-slim AS production-deps
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
ENV NODE_ENV=production
ARG REVISION=dev
ENV APP_REVISION=$REVISION
LABEL org.opencontainers.image.revision=$REVISION
COPY --from=production-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
COPY --chown=node:node db ./db
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Multi-stage builds leave compilers and test files in the build stage instead of the final image. See Docker multi-stage builds.

Exec-form CMD starts Node directly so it can receive termination signals. The application then stops accepting connections and closes its pool. EXPOSE 3000 documents a port rather than publishing it. A non-root process also needs correct filesystem permissions; USER alone does not make every directory writable. See the Dockerfile reference.

7.3 Context exclusions, secrets, and configuration

.dockerignore:

node_modules
dist
.git
.env
.env.*
reports
npm-debug.log*

The image build does not need .env.example, so it is excluded too. In the practice repository's .gitignore, exclude node_modules, dist, reports, and the real .env, but retain .env.example, source, and the lockfile.

ARG supplies build input and ENV can establish runtime defaults. Neither is a secret storage mechanism. Private-package credentials should use facilities such as build secret mounts, not a copied credential file deleted in a later layer. See Docker build secrets.

Tags can move, and even commit-based tags need a policy against replacement. Digests identify content. Record both the commit and digest for a release. Shared images do not eliminate CPU architecture, kernel, configuration, or external dependency differences; native modules must match the target runtime too.

8. Compose: declare dependencies, readiness, and data lifetime

8.1 Local environment values

.env.example contains practice values and is copied to .env. Deployment credentials should come from the deployment system, not this password.

POSTGRES_PASSWORD=local-demo-only
APP_REVISION=dev

Compose uses .env for interpolation; that does not automatically inject every variable into a container. The configuration below passes them explicitly through environment. A separate PGPASSWORD avoids building a connection URL with unencoded special characters. See Compose interpolation.

8.2 Application, one-shot migration, and database

compose.yaml:

x-app: &app
  image: delivery-demo:local
  build:
    context: .
    args:
      REVISION: ${APP_REVISION:-dev}
  environment:
    PGHOST: db
    PGPORT: "5432"
    PGUSER: demo
    PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
    PGDATABASE: delivery
 
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: demo
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
      POSTGRES_DB: delivery
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U demo -d delivery"]
      interval: 5s
      timeout: 3s
      retries: 10
 
  migrate:
    <<: *app
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"
 
  api:
    <<: *app
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      db:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 5s
      timeout: 4s
      retries: 10
      start_period: 10s
    stop_grace_period: 15s
 
volumes:
  db-data:

The API connects to db by service name; localhost inside a container means that container. PostgreSQL is not published to the host, but the application can still reach it over the Compose network. The API binds only to the host loopback address for this local demonstration.

A started database container is not necessarily ready. This configuration waits for its healthcheck, completes migration, then starts the API. The application still needs to recover from later disconnections; depends_on does not continuously manage connections. See Compose startup order.

pg_isready establishes that PostgreSQL accepts connections, not that application credentials, schema, and business queries all work. Migration and application readiness remain useful checks.

8.3 Run, inspect, and stop

After creating the files above and installing Node 24:

npm install
cp .env.example .env
bash scripts/check.sh
docker compose config --quiet
docker compose up -d --build --wait --wait-timeout 120
curl --fail http://127.0.0.1:3000/readyz
curl --fail http://127.0.0.1:3000/version
docker compose ps
docker compose logs --tail=100 api migrate db

Readiness should return ready and version should return dev. The first npm install creates package-lock.json; commit it, then use npm ci locally and in CI. --wait uses service running or health status, not complete business verification. See docker compose up.

Tests are not copied into the runtime image, so do not run npm test in the API container. CI's host Node process runs integration tests against a temporary database, and image construction runs unit tests in the build stage.

docker compose stop stops containers. down removes containers and networks while retaining named volumes by default. down -v removes this project's declared named volumes and associated anonymous volumes; use it only after explicitly accepting loss of the practice database. A volume is a persistence location, not a backup.

Bind mounts can support local source reload, but mounting /app can hide node_modules or dist already in the image. Design development reload separately from deployment images rather than treating host dependencies as portable container dependencies.

9. Release and rollback: run a known artifact

9.1 CI, continuous delivery, and continuous deployment

CI provides automated integration feedback. Continuous delivery keeps changes releasable, potentially after human approval. Continuous deployment also moves qualifying changes into the target environment automatically. A workflow containing a push command does not establish a production deployment.

The image job publishes to GHCR and saves image.txt. Initial use still requires checking package permissions and registry policy; the workflow does not configure a server or deployment credentials. Prefer promoting the same digest through environments, supplying their differences as external configuration rather than rebuilding a same-named image for each one.

9.2 Explicit assumptions for a single-host deployment

The separate compose.deploy.yaml below assumes a host with Docker, registry pull access, and an external reverse proxy. It pulls images without build configuration. The database volume lives on that host; this example does not promise high availability or zero downtime.

x-runtime: &runtime
  image: ${APP_IMAGE:?Set APP_IMAGE to a registry digest}
  environment:
    PGHOST: db
    PGPORT: "5432"
    PGUSER: demo
    PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
    PGDATABASE: delivery
 
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: demo
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
      POSTGRES_DB: delivery
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U demo -d delivery"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped
 
  migrate:
    <<: *runtime
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"
 
  api:
    <<: *runtime
    ports:
      - "127.0.0.1:3000:3000"
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 5s
      timeout: 4s
      retries: 10
      start_period: 10s
    restart: unless-stopped
    stop_grace_period: 15s
 
volumes:
  db-data:

Save the following as scripts/deploy.sh. A deployment controller should serialize calls and supply APP_IMAGE and POSTGRES_PASSWORD. APP_IMAGE comes from the reviewed full reference in image.txt, not whichever image latest happens to select on the server.

#!/usr/bin/env bash
set -euo pipefail
: "${APP_IMAGE:?Set APP_IMAGE}"
: "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}"
if [[ ! "$APP_IMAGE" =~ ^ghcr\.io/.+@sha256:[0-9a-f]{64}$ ]]; then
  printf 'APP_IMAGE must be a GHCR digest reference\n' >&2
  exit 2
fi
export APP_IMAGE POSTGRES_PASSWORD
compose=(docker compose -p delivery -f compose.deploy.yaml)
"${compose[@]}" config --quiet
"${compose[@]}" pull
"${compose[@]}" up -d --wait --wait-timeout 120 db
"${compose[@]}" run --rm --no-deps migrate
"${compose[@]}" up -d --no-deps --wait --wait-timeout 120 api
curl --fail --silent --show-error --max-time 5 http://127.0.0.1:3000/readyz
curl --fail --silent --show-error --max-time 5 http://127.0.0.1:3000/version

Run from the practice project's root. A failed migration exits before replacing the API. Failed readiness also returns nonzero, but this script does not automatically restore the previous container, so the controller must handle that failure. Save the prior digest and observe errors, latency, and important operations beyond a successful curl.

The database major version must match its data directory. postgres:16 is fixed here for readability; production should manage digests and upgrades too. Changing POSTGRES_PASSWORD in the environment does not automatically change a password already stored in an existing database volume.

9.3 Rolling back an application does not roll back data

If the new application fails and the schema remains compatible, restore the API to the previously saved PREVIOUS_IMAGE:

: "${PREVIOUS_IMAGE:?Set the previously verified digest}"
APP_IMAGE="$PREVIOUS_IMAGE" docker compose -p delivery -f compose.deploy.yaml \
  up -d --no-deps --pull always --wait --wait-timeout 120 api

This does not rerun old migrations or delete newly written data, and valid database environment configuration is still required. If a migration removed a column needed by the old application, changing the image alone cannot restore service. Prefer compatible additions, backfills, read/write transitions, and removal of old structures in a later release.

Zero downtime, high availability, and multi-instance traffic switching need additional blue-green or rolling strategies and load balancing. A single-host Compose replacement should not be described as providing those automatically.

10. Trace symptoms to the failing stage

Symptom Check first Avoid
Local checks pass, CI fails Node, lockfile, Linux filename casing, missing variables Ignoring failed steps
Tests fail but CI stays green Pipeline status, continue-on-error, overwritten exit codes Reading only the last log line
Image builds but does not start dist, runtime dependencies, entry paths, permissions Reinstalling all development tools to hide missing files
Container cannot reach PostgreSQL Service names, ports, network, credentials, readiness, migration logs Continuing to use host localhost inside the container
Data disappears after recreation Mounts, project names, database data directory Repeated down -v commands
Wrong version is released Digest, commit metadata, release record, target host Pushing latest again and guessing
Rollback still fails Schema compatibility, changed configuration, external services Assuming image rollback undoes data changes

Preserve logs and the running version before changing configuration. Review logs for sensitive data before copying full environments or database connection details into public issues.

11. Practical checks and self-assessment

Try these experiments in the independent practice project:

  • Fail a unit test and confirm check.sh returns nonzero while preserving logs in reports.
  • Use a project path containing spaces and verify that the script still finds its root.
  • Stop the practice database, compare healthz with readyz, and observe recovery after restarting it.
  • Remove build output and confirm tests or image execution fail explicitly rather than testing old code silently.
  • Compare cache behavior after changing only source versus changing the lockfile.
  • Confirm that ordinary PRs do not publish and failed main verification blocks image publication.
  • Before an incompatible migration, explain whether the previous application can still run.
  • Retain a known digest, rehearse application rollback, and inspect version afterward.

Questions worth answering:

  1. Why inspect diff --cached after staging? The commit comes from the index, which may differ from working files.
  2. Why does rebase change commit IDs? It recreates commits and their parent relationships rather than merely rearranging a display.
  3. Why does tee success not establish test success? It is the pipeline's last stage; preserve upstream failure status.
  4. Are caches and artifacts interchangeable? Caches accelerate reproducible inputs or intermediates; artifacts record a run's outputs.
  5. Why does depends_on not guarantee permanent database connectivity? It handles startup conditions rather than application connection management.
  6. Can a build argument safely store a private key? No; secrets need dedicated transmission and access mechanisms.
  7. Why record a digest? Tags can move; diagnosis and rollback need a definite artifact identity.
  8. Why can a release be irreversible even when its image can be reverted? Migrations and external effects may already have changed compatibility.

Understanding the inputs, artifacts, permissions, failure states, and recovery at each step turns these tools into an explainable delivery process.