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
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)));
{
"name": "demo",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"scripts": { "start": "node server.js" }
}
2. The 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 \
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 /app/node_modules ./node_modules
COPY 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"]
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.
- Cache mounts
- Build secrets
- Passing a secret
- Bind mounts
# Package-manager caches survive between builds but never enter a layer.
RUN npm ci
# Other ecosystems:
RUN go mod download
RUN pip install -r requirements.txt
RUN
\
apt-get update && apt-get install -y --no-install-recommends curl
# The secret is mounted as a file for the duration of this RUN only.
# It is never written to a layer, so it cannot be extracted afterwards.
RUN \
npm ci --omit=dev
docker build --secret id=npmrc,src=$HOME/.npmrc -t demo:dev .
# NEVER do this — the value is baked into image metadata forever
# docker build --build-arg NPM_TOKEN=$NPM_TOKEN .
# Read files from the build context without COPYing them into a layer.
RUN
\
npm ci --omit=dev
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
| Tag | Mutable? | Use for |
|---|---|---|
latest | Yes | Local convenience only |
1.0.0 | Should not be | Human-readable releases |
sha-a1b2c3d | No | The tag CI should always produce |
@sha256:… | Never | What 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, orgcr.io/distroless/*. -
.dockerignoreexcludes.git,node_modules, and local env files. - Package manager caches removed, or better, mounted with
--mount=type=cache. - Volatile
COPYinstructions placed last so dependency layers stay cached.
Next steps
- Set up a CI/CD pipeline with GitHub Actions — automate all of the above on every push.
- Deploy your first app — run the image you just built.
- Docker fundamentals — the layer model behind the caching rules.