| name | docker |
| description | Docker standards for Node.js/pnpm apps. Auto-load when writing or reviewing Dockerfiles, .dockerignore, or docker-compose files. |
| user-invocable | false |
| autoload-when-editing | ["**/Dockerfile","**/Dockerfile.*","**/.dockerignore","**/docker-compose*.yml","**/docker-compose*.yaml"] |
| autoload-dirs | ["/Users/walid/Development/nextnode","/Users/walid/Development/saas"] |
Docker Standards
Auto-loads when writing or reviewing any Docker-related file. Every rule is mandatory — no exceptions.
1. Foundational Rules
- Multi-stage builds ALWAYS — minimum 2 stages:
builder + runtime. No single-stage production Dockerfiles.
# syntax=docker/dockerfile:1 on line 1 of every Dockerfile — unlocks BuildKit features (cache mounts, secret mounts, heredocs).
node:XX-slim (Debian) as base — NOT full node:XX, NOT alpine (musl breaks native modules silently). Exception: pure JS apps with zero native deps MAY use alpine if explicitly justified.
- Never hardcode pnpm version — use
corepack prepare --activate (reads packageManager from package.json). NEVER corepack prepare pnpm@X.Y.Z --activate. IMPORTANT: corepack prepare --activate requires package.json (with packageManager field) to exist in the current directory — always COPY package.json ./ BEFORE running it in any stage.
--frozen-lockfile on every pnpm install — no exceptions.
pnpm.onlyBuiltDependencies MUST be set in package.json — this is the allowlist of packages permitted to run install scripts (e.g. better-sqlite3, esbuild, sharp). Everything else is blocked by default in pnpm v10+. NEVER use --ignore-scripts (breaks native module builds) or CI=true.
- Important distinction:
onlyBuiltDependencies controls dependency install/postinstall scripts (packages in node_modules), NOT the root project's lifecycle scripts (like prepare). The root prepare script (e.g. "prepare": "husky") still runs after every pnpm install.
- Prod-deps stage and husky:
--prod skips devDependencies (husky isn't installed) but pnpm still triggers the root prepare lifecycle hook → sh: husky: not found. Fix: strip the prepare script from package.json before the prod install (scripts don't affect lockfile resolution). HUSKY=0 does NOT help here — it makes the husky command a no-op, but the binary doesn't even exist in --prod so sh fails before husky can read the env var. See prod-deps stage in canonical templates.
- Exec form ALWAYS for
CMD and ENTRYPOINT — CMD ["node", "dist/server.js"], never CMD node server.js.
- Non-root user in the runtime stage — switch with
USER before CMD.
- Signal handler (tini or dumb-init) as PID 1 — Node.js must not run as PID 1.
- HEALTHCHECK instruction in the Dockerfile — never in
docker-compose.yml.
2. NextNode Ecosystem Integration
Source of truth: The nextnode-infra skill owns all deploy-time behavior — auto-generated env vars, compose transformation, port allocation, route handling, service scaffolding, and the "What to Add vs What NOT to Add" guide. Always consult nextnode-infra when working on NextNode Docker/Compose files. This section covers only the Dockerfile/Compose authoring rules.
When a project uses nextnode.toml, Docker config MUST derive values from it — never hardcode what the infrastructure manages.
Port Naming Convention
Dockerfile ARGs MUST use the infra CLI's _{SERVICE} suffix convention to stay consistent with the compose env vars the CLI generates. For the main app service, the suffix is _APP:
ARG APP_PORT_APP=4321
ENV PORT=$APP_PORT_APP
EXPOSE $APP_PORT_APP
The default value MUST match [deploy].port from nextnode.toml. The infra CLI generates APP_PORT_APP (and HOST_PORT_APP) into the .env at deploy time — the Dockerfile ARG name matches so there's a single naming convention across Dockerfile and compose.
For multi-service projects with [[routes]], each service gets its own suffix:
- Main app:
APP_PORT_APP (from [deploy].port)
- Route service:
APP_PORT_{SERVICE} (from [[routes]].port, e.g. APP_PORT_STRAPI)
docker-compose.yml Rules
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "${HOST_PORT_APP}:${APP_PORT_APP}"
restart: unless-stopped
environment:
- NODE_ENV=${NODE_ENV}
- No
env_file — CLI injects .env at deploy time
- No
container_name — let Compose auto-name
- No
proxy-public network — Caddy runs natively on VPS
- No inline
healthcheck — belongs in Dockerfile
restart: unless-stopped always
For multi-service builds, [[routes]], resource limits, compose transformation at deploy time, and the full list of infra-managed env vars — see nextnode-infra skill.
3. Layer Ordering (Stable → Volatile)
Order instructions from what changes LEAST to what changes MOST. A changed layer invalidates all subsequent layers.
1. FROM base image (changes: on security patches)
2. System packages (changes: rarely)
3. WORKDIR (changes: never)
4. package.json COPY (changes: slightly more often)
5. corepack enable + prepare (changes: when packageManager changes)
6. Non-root user creation (changes: never)
7. pnpm-lock.yaml COPY (changes: when deps change)
8. pnpm install (changes: when deps change)
9. Source files COPY (changes: every build)
10. Build command (changes: every build)
11. Runtime COPY --from=build (changes: every build)
12. ENV / LABEL / HEALTHCHECK (changes: rarely)
13. USER (changes: never)
14. ENTRYPOINT / CMD (changes: rarely)
Critical mistakes:
- NEVER
COPY . . before pnpm install — causes full reinstall on every source change
- NEVER split
apt-get update && apt-get install across two RUN layers — the apt cache layer goes stale
4. Build Time Optimization
4.1 pnpm install with cache mounts
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
The BuildKit cache mount persists the pnpm store across builds — repeat installs with unchanged lockfile resolve from cache instantly.
WARNING — pnpm fetch + --offline is broken. The cache mount target /pnpm/store does NOT match pnpm's actual default store path (~/.local/share/pnpm/store). pnpm fetch writes to the default store, the cache mount persists an empty /pnpm/store, and pnpm install --offline silently installs nothing from the empty mount. NEVER use pnpm fetch/--offline with cache mounts — use direct pnpm install --frozen-lockfile instead.
4.2 Parallel stages for prod/build deps
BuildKit runs independent stages concurrently:
FROM base AS prod-deps
COPY pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --prod
FROM base AS build
COPY pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY tsconfig*.json ./
COPY src/ ./src/
RUN pnpm build
prod-deps and build execute in parallel — total build time is max(stage A, stage B), not sum.
4.3 BuildKit cache mounts
Always use --mount=type=cache for package managers:
# pnpm store
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
# apt (needs sharing=locked)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
4.4 Selective COPY (not COPY . .)
Copy only what the build needs — prevents cache busting from unrelated file changes:
# Build stage — copy only build inputs
COPY tsconfig*.json ./
COPY src/ ./src/
COPY public/ ./public/
RUN pnpm build
4.5 CI registry cache
For CI (ephemeral runners), use registry-backed cache:
docker buildx build \
--cache-from type=registry,ref=ghcr.io/org/app:buildcache \
--cache-to type=registry,ref=ghcr.io/org/app:buildcache,mode=max \
--tag ghcr.io/org/app:latest \
--push .
mode=max exports ALL intermediate layers — critical for multi-stage cache hits.
5. Image Size Optimization
5.1 Base image: node:XX-slim
| Base | Size | CVEs | Shell | Recommendation |
|---|
node:22 | ~1 GB | High | Yes | NEVER in production |
node:22-slim | ~220 MB | Low | Yes | Default choice |
node:22-alpine | ~55 MB | Very low | sh | Only pure JS apps |
distroless/nodejs22 | ~100 MB | Near zero | No | Maximum security |
5.2 Production-only node_modules
ALWAYS use --prod in the runtime install or copy from a dedicated prod-deps stage:
COPY --from=prod-deps /app/node_modules ./node_modules
NEVER ship devDependencies to production.
5.3 Copy ONLY runtime artifacts
# Runtime stage receives ONLY:
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
# NEVER copy: src/, tsconfig.json, test files, docs, .git
5.4 Clean up in the SAME RUN layer
# CORRECT — single layer, no bloat
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
# WRONG — rm runs in new layer, old layer still has cache
RUN apt-get install -y tini
RUN rm -rf /var/lib/apt/lists/*
5.5 Always --no-install-recommends
Prevents apt from pulling suggested packages — cuts installed size 30-50%.
5.6 Strip SUID/SGID bits
RUN find / -type f -perm /6000 -exec chmod -s {} \; 2>/dev/null || true
Removes setuid/setgid binaries that could enable privilege escalation.
6. Security Hardening
6.1 Non-root user (MANDATORY)
# Debian slim
RUN groupadd -g 1001 appgroup \
&& useradd -u 1001 -g appgroup --no-log-init --create-home appuser
# Alpine
RUN addgroup -g 1001 -S appgroup \
&& adduser -S -u 1001 -G appgroup appuser
Use COPY --chown=appuser:appgroup to set ownership at copy time — cheaper than a separate RUN chown.
Switch to non-root AFTER all file operations, BEFORE CMD:
USER appuser
ENTRYPOINT ["/usr/bin/tini", "-g", "--"]
CMD ["node", "dist/server.js"]
6.2 No secrets in layers (MANDATORY)
# WRONG — visible in docker history
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
# CORRECT — ephemeral, never in any layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
pnpm install --frozen-lockfile
Rules:
- NEVER use
ARG or ENV for secrets
- NEVER
COPY .npmrc then RUN rm .npmrc — the file exists in the intermediate layer
- Use
--mount=type=secret for private registries, tokens, credentials
6.3 COPY over ADD
Always use COPY for local files. ADD is only acceptable for auto-extracting tar archives with checksum validation.
6.4 Signal handling with tini
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "-g", "--"]
CMD ["node", "dist/server.js"]
On Alpine: RUN apk add --no-cache tini → /sbin/tini.
6.5 Health check (MANDATORY for apps)
Use Node.js built-in HTTP — never install curl just for health checks:
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["node", "-e", "require('http').get('http://localhost:' + (process.env.PORT || 4321) + '/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
Parameters:
--start-period: set to actual app startup time (grace before failures count)
--interval: 30s is standard
--retries: 3 before marking unhealthy
6.6 Read-only filesystem at runtime
Enforce in compose or docker run:
services:
app:
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
6.7 Drop capabilities
services:
app:
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
6.8 OCI labels
ARG BUILD_DATE
ARG VCS_REF
ARG APP_VERSION
LABEL org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${VCS_REF}" \
org.opencontainers.image.version="${APP_VERSION}" \
org.opencontainers.image.source="https://github.com/org/app" \
org.opencontainers.image.title="App Name" \
org.opencontainers.image.licenses="MIT"
7. Canonical Dockerfile — Single App (pnpm)
# syntax=docker/dockerfile:1
ARG NODE_VERSION=22
ARG APP_PORT_APP=4321
# ── Base ─────────────────────────────────────────────────
FROM node:${NODE_VERSION}-slim AS base
WORKDIR /app
COPY package.json ./
RUN corepack enable && corepack prepare --activate
ENV PNPM_HOME="/pnpm" \
PATH="/pnpm:$PATH"
# ── Prod deps ────────────────────────────────────────────
FROM base AS prod-deps
COPY pnpm-lock.yaml ./
# Strip "prepare" script: runs `husky` (devDep, not in --prod).
# Scripts don't affect lockfile resolution.
RUN node -e "const p=JSON.parse(require('fs').readFileSync('package.json','utf8'));delete p.scripts.prepare;require('fs').writeFileSync('package.json',JSON.stringify(p))"
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --prod
# ── Build ────────────────────────────────────────────────
FROM base AS build
COPY pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY tsconfig*.json ./
COPY src/ ./src/
RUN pnpm build
# ── Runtime ──────────────────────────────────────────────
FROM node:${NODE_VERSION}-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1001 appgroup \
&& useradd -u 1001 -g appgroup --no-log-init --create-home appuser
WORKDIR /app
COPY --chown=appuser:appgroup --from=prod-deps /app/node_modules ./node_modules
COPY --chown=appuser:appgroup --from=build /app/dist ./dist
COPY --chown=appuser:appgroup package.json ./
ARG APP_PORT_APP
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=${APP_PORT_APP}
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["node", "-e", "require('http').get('http://localhost:' + (process.env.PORT || 4321) + '/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
EXPOSE ${APP_PORT_APP}
USER appuser
ENTRYPOINT ["/usr/bin/tini", "-g", "--"]
CMD ["node", "dist/server.js"]
Astro SSR Variant
Replace the CMD and add Astro-specific env:
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=${APP_PORT_APP} \
ASTRO_TELEMETRY_DISABLED=1
CMD ["node", "dist/server/entry.mjs"]
Apps with build-time public env vars (Vite/Astro)
When PUBLIC_* vars must be inlined at build time:
FROM base AS build
COPY pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
# PUBLIC_ vars inlined by Vite at build time
ARG PUBLIC_SUPABASE_URL
ARG PUBLIC_SUPABASE_ANON_KEY
ARG PUBLIC_SITE_URL
ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \
PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \
PUBLIC_SITE_URL=$PUBLIC_SITE_URL
COPY tsconfig*.json ./
COPY src/ ./src/
COPY public/ ./public/
RUN pnpm build
These are NOT secrets — they are public client-side values embedded in the JS bundle.
Apps with native modules (better-sqlite3, sharp, etc.)
Prerequisite: package.json MUST declare pnpm.onlyBuiltDependencies — this is the allowlist of packages permitted to run install/build scripts. Without it, pnpm v10+ blocks all lifecycle scripts by default.
{
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"sharp"
]
}
}
When native addons need compilation tools, install them in the builder stage only:
FROM base AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# ... rest of build stage
# These packages are NOT in the runtime stage
--ignore-scripts is NEVER needed — onlyBuiltDependencies handles script control declaratively. Using --ignore-scripts would override the allowlist and break native module compilation.
8. Canonical Dockerfile — Monorepo (Turborepo + pnpm)
# syntax=docker/dockerfile:1
ARG NODE_VERSION=22
ARG APP_PORT_APP=3000
# ── Base ─────────────────────────────────────────────────
FROM node:${NODE_VERSION}-slim AS base
WORKDIR /app
COPY package.json ./
RUN corepack enable && corepack prepare --activate
ENV PNPM_HOME="/pnpm" \
PATH="/pnpm:$PATH"
# ── Install + Build ─────────────────────────────────────
FROM base AS build
COPY pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
RUN pnpm turbo build
# ── Prod deps ────────────────────────────────────────────
FROM base AS prod-deps
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json ./apps/api/
COPY packages/shared/package.json ./packages/shared/
# Strip "prepare" script: runs `husky` (devDep, not in --prod).
RUN node -e "const p=JSON.parse(require('fs').readFileSync('package.json','utf8'));delete p.scripts.prepare;require('fs').writeFileSync('package.json',JSON.stringify(p))"
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --prod
# ── Runtime ──────────────────────────────────────────────
FROM node:${NODE_VERSION}-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1001 appgroup \
&& useradd -u 1001 -g appgroup --no-log-init --create-home appuser
WORKDIR /app
COPY --chown=appuser:appgroup --from=prod-deps /app/node_modules ./node_modules
COPY --chown=appuser:appgroup --from=prod-deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --chown=appuser:appgroup --from=prod-deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --chown=appuser:appgroup --from=build /app/apps/api/dist ./apps/api/dist
COPY --chown=appuser:appgroup --from=build /app/apps/web/dist ./apps/web/dist
COPY --chown=appuser:appgroup --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --chown=appuser:appgroup package.json ./
ARG APP_PORT_APP
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=${APP_PORT_APP}
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["node", "-e", "require('http').get('http://localhost:' + (process.env.PORT || 3000) + '/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
EXPOSE ${APP_PORT_APP}
USER appuser
ENTRYPOINT ["/usr/bin/tini", "-g", "--"]
CMD ["node", "apps/api/dist/index.js"]
Monorepo alternative — pnpm deploy: For cleaner isolation, use pnpm deploy --filter=api --prod /prod/api to extract a single workspace with only its production deps into an isolated directory.
9. Canonical .dockerignore
Every project with a Dockerfile MUST have a .dockerignore:
# Version control
.git
.gitignore
.gitattributes
.github/
# Dependencies (rebuilt inside container)
node_modules/
.pnpm-store/
# Build artifacts (rebuilt inside container)
dist/
build/
.astro/
.next/
.nuxt/
out/
# TypeScript cache
*.tsbuildinfo
# Test output
coverage/
.nyc_output/
# Logs
*.log
pnpm-debug.log*
# Environment (NEVER in image)
.env
.env.*
!.env.example
.envrc
# Credentials (NEVER in image)
.npmrc
*.pem
*.key
# Editor/OS
.vscode/
.idea/
.DS_Store
*.swp
# Documentation
*.md
docs/
LICENSE
CHANGELOG
# Claude / AI
.claude/
# Docker files (already in context)
Dockerfile
Dockerfile.*
.dockerignore
docker-compose*.yml
docker-compose*.yaml
10. Anti-Patterns (NEVER DO)
| # | Anti-Pattern | Correct Approach |
|---|
| 1 | corepack prepare pnpm@X.Y.Z --activate | corepack prepare --activate — reads packageManager from package.json |
| 2 | --ignore-scripts / ENV CI=true | pnpm.onlyBuiltDependencies allowlist in package.json — blocks all dependency scripts except listed packages. For the root prepare lifecycle (husky) in prod-deps stage, strip the script before install (see canonical templates). Never use --ignore-scripts (breaks native module builds). |
| 3 | COPY . . before pnpm install | Copy pnpm-lock.yaml first, then package.json, then install, then source |
| 4 | CMD npm start or CMD pnpm start | CMD ["node", "dist/server.js"] — exec form, direct node invocation. Exception: frameworks that need their CLI for bootstrapping (e.g. Strapi needs pnpm start for migration running, TypeScript detection). When using pnpm in CMD, corepack + PNPM_HOME must also be configured in the runtime stage |
| 5 | Running as root in production | Create dedicated user, USER appuser before CMD |
| 6 | Node.js as PID 1 | ENTRYPOINT ["/usr/bin/tini", "-g", "--"] |
| 7 | Installing curl just for HEALTHCHECK | Use node -e "require('http')..." |
| 8 | ADD for local files | COPY always — ADD only for tar extraction |
| 9 | ARG NPM_TOKEN for secrets | --mount=type=secret,id=npmrc,target=/root/.npmrc |
| 10 | Separate RUN apt-get update and RUN apt-get install | Single RUN with && — prevents stale apt cache |
| 11 | Missing .dockerignore | Always create — prevents sending .git, node_modules, .env to daemon |
| 12 | EXPOSE 4321 with hardcoded value | ARG APP_PORT_APP=4321 + EXPOSE ${APP_PORT_APP} — use infra CLI's _{SERVICE} suffix convention |
| 13 | RUN chown -R user:group /app as separate layer | COPY --chown=user:group at copy time |
| 14 | Full node:22 as runtime base | node:22-slim — 1 GB vs 220 MB |
| 15 | pnpm fetch + pnpm install --offline with cache mounts | Cache mount at /pnpm/store doesn't match pnpm's default store path — --offline finds an empty store and silently installs nothing. Use pnpm install --frozen-lockfile with cache mounts directly |
| 16 | Copying devDependencies to runtime | Use --prod on install or separate prod-deps stage |
| 17 | Missing # syntax=docker/dockerfile:1 | Always first line — enables cache mounts, secret mounts |
| 18 | pnpm install without --frozen-lockfile | Always --frozen-lockfile — deterministic builds |
11. Review Checklist
When reviewing any Dockerfile, verify ALL of the following:
Structure:
Build optimization:
Size optimization:
Security:
Runtime:
Files:
12. Buildx Builder
Buildx with docker-container driver is REQUIRED — enables --mount=type=cache, --mount=type=secret, and multi-platform builds.
12.1 Create
docker buildx create \
--name nextnode-builder \
--driver docker-container \
--platform linux/amd64,linux/arm64 \
--bootstrap \
--use
--driver docker-container — BuildKit in a dedicated container, full feature set
--platform linux/amd64,linux/arm64 — cross-compile (deploy = amd64, dev may be arm64)
--bootstrap --use — pull image, start, set as default
Do NOT pass --buildkitd-flags '--oci-worker-no-process-sandbox' unless rootless Docker — causes boot loop otherwise.
12.2 Build commands
docker buildx build --load -t myapp:latest .
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/org/app:latest \
--push .
docker buildx build \
--cache-from type=registry,ref=ghcr.io/org/app:buildcache \
--cache-to type=registry,ref=ghcr.io/org/app:buildcache,mode=max \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/org/app:latest \
--push .
12.3 Maintenance
docker buildx prune
docker buildx stop nextnode-builder
docker buildx inspect --bootstrap nextnode-builder
docker buildx rm nextnode-builder