Skip to main content

Build and publish a Docker image

This guide takes a small Node.js service from source to a signed, multi-architecture image on GitHub Container Registry. The techniques transfer directly to Go, Python, Rust, or anything else.

Time: about 25 minutes. Prerequisites: Docker 24+ with BuildKit (the default since 23.0), and a GitHub account.

1. The application

server.js
import http from 'node:http';

const port = process.env.PORT ?? 8080;

const server = http.createServer((req, res) => {
if (req.url === '/healthz') {
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('ok');
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ service: 'demo', arch: process.arch, node: process.version }));
});

server.listen(port, () => console.log(`listening on :${port}`));

// Containers receive SIGTERM on shutdown. Without this handler the process is
// SIGKILLed after the grace period and in-flight requests are dropped.
process.on('SIGTERM', () => server.close(() => process.exit(0)));
package.json
{
"name": "demo",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"scripts": { "start": "node server.js" }
}

2. The Dockerfile

Dockerfile
# syntax=docker/dockerfile:1

ARG NODE_VERSION=22

# ---- dependencies ----------------------------------------------------------
FROM node:${NODE_VERSION}-alpine AS deps
WORKDIR /app

# Copying only the manifests means this layer is reused until a dependency
# actually changes — editing server.js will not trigger a reinstall.
COPY package.json package-lock.json* ./

# A cache mount keeps npm's cache between builds without baking it into a layer.
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev

# ---- runtime ---------------------------------------------------------------
FROM node:${NODE_VERSION}-alpine AS runtime
ENV NODE_ENV=production \
PORT=8080

WORKDIR /app

# The node images already ship an unprivileged `node` user (UID 1000).
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json server.js ./

USER node
EXPOSE 8080

# dumb-init style signal handling is unnecessary here because we exec node
# directly as PID 1 and handle SIGTERM in the app.
ENTRYPOINT ["node", "server.js"]
.dockerignore
node_modules
npm-debug.log
.git
.github
.env*
Dockerfile
.dockerignore
README.md
coverage

3. Build and run locally

docker build -t demo:dev .
docker run --rm -p 8080:8080 demo:dev

# In another terminal
curl -s localhost:8080 | jq
curl -s localhost:8080/healthz

Check what you actually produced:

docker image ls demo:dev
docker history demo:dev # layer-by-layer size
docker inspect demo:dev --format '{{.Config.User}} {{.Config.Entrypoint}}'

4. Cache mounts and build secrets

Two BuildKit features worth adopting immediately.

# Package-manager caches survive between builds but never enter a layer.
RUN --mount=type=cache,target=/root/.npm npm ci

# Other ecosystems:
RUN --mount=type=cache,target=/go/pkg/mod go mod download
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
RUN --mount=type=cache,target=/var/cache/apt \
--mount=type=cache,target=/var/lib/apt \
apt-get update && apt-get install -y --no-install-recommends curl

5. Multi-architecture builds

Apple Silicon laptops are arm64; most cloud nodes are amd64. A single-arch image will fail to start on the other one with exec format error.

# Create a builder that supports multiple platforms via QEMU emulation
docker buildx create --name multiarch --driver docker-container --use
docker buildx inspect --bootstrap

# Build both architectures and push as one manifest list
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/OWNER/demo:1.0.0 \
--push .

A "multi-arch image" is really an image index (manifest list): a small JSON document mapping each platform to a per-platform manifest. When a node pulls the tag, the registry serves it the entry matching its own architecture.

docker buildx imagetools inspect ghcr.io/OWNER/demo:1.0.0

6. Publish to GHCR

# A classic PAT with write:packages scope, or GITHUB_TOKEN inside Actions
echo "$CR_PAT" | docker login ghcr.io -u OWNER --password-stdin

docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/OWNER/demo:1.0.0 \
-t ghcr.io/OWNER/demo:latest \
--push .

New GHCR packages are private by default. Make it public under the package's settings on GitHub, or create an image pull secret in your cluster:

kubectl create secret docker-registry ghcr \
--docker-server=ghcr.io \
--docker-username=OWNER \
--docker-password="$CR_PAT"

7. Tagging strategy

TagMutable?Use for
latestYesLocal convenience only
1.0.0Should not beHuman-readable releases
sha-a1b2c3dNoThe tag CI should always produce
@sha256:…NeverWhat production manifests should reference

Deploy by digest. A tag can be repointed at different content; a digest cannot. This is what makes a rollback deterministic.

docker buildx imagetools inspect ghcr.io/OWNER/demo:1.0.0 --format '{{.Manifest.Digest}}'

8. Shrink and scan

# What is actually taking up space?
docker history --no-trunc ghcr.io/OWNER/demo:1.0.0

# Vulnerability scan before you ship
trivy image --severity HIGH,CRITICAL ghcr.io/OWNER/demo:1.0.0

# Generate an SBOM and provenance attestation at build time
docker buildx build --sbom=true --provenance=true -t ghcr.io/OWNER/demo:1.0.0 --push .

Size checklist:

  • Multi-stage build — no compilers or dev dependencies in the final stage.
  • A slim base: alpine, -slim, or gcr.io/distroless/*.
  • .dockerignore excludes .git, node_modules, and local env files.
  • Package manager caches removed, or better, mounted with --mount=type=cache.
  • Volatile COPY instructions placed last so dependency layers stay cached.

Next steps