Container and Runtime Images
Production services that follow these standards MUST ship as OCI images built from reproducible Dockerfiles in the repository.
1. Dockerfile Rules
- MUST use multi-stage builds:
buildstage compiles;runtimestage runs only the published artifact. - MUST pin base image digests or minor tags (for example
mcr.microsoft.com/dotnet/aspnet:10.0, notlatest). - MUST run the application process as a non-root user in the runtime stage.
- MUST expose only required ports (typically 8080 for ASP.NET Core, 3000 for Next.js standalone).
- MUST NOT bake secrets, connection strings, or API keys into image layers.
# GOOD: non-root runtime userFROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtimeWORKDIR /appUSER $APP_UIDCOPY --from=build /app/publish .ENTRYPOINT ["dotnet", "MyProject.WebApi.dll"]# BAD: secrets in build args copied into layersARG DATABASE_PASSWORDENV ConnectionStrings__Default=$DATABASE_PASSWORD2. Complete Backend Dockerfile
Prefer the copy-paste template at docs/templates/config/Dockerfile.api over inlining this block.
# ---- build stage ----FROM mcr.microsoft.com/dotnet/sdk:10.0 AS buildWORKDIR /src
# Restore first for layer cachingCOPY ["apps/api/src/{ProjectName}.WebApi/{ProjectName}.WebApi.csproj", "apps/api/src/{ProjectName}.WebApi/"]COPY ["apps/api/src/{ProjectName}.Infrastructure/{ProjectName}.Infrastructure.csproj", "apps/api/src/{ProjectName}.Infrastructure/"]# ... copy all .csproj files before copying source ...COPY ["apps/api/Directory.Build.props", "apps/api/"]COPY ["apps/api/Directory.Packages.props", "apps/api/"]COPY ["apps/api/{ProjectName}.slnx", "apps/api/"]WORKDIR /src/apps/apiRUN dotnet restore "{ProjectName}.slnx"
WORKDIR /srcCOPY . .WORKDIR /src/apps/apiRUN dotnet publish "src/{ProjectName}.WebApi/{ProjectName}.WebApi.csproj" \ --configuration Release \ --no-restore \ --output /app/publish
# ---- runtime stage ----FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtimeWORKDIR /app
# Set non-root user (APP_UID is defined in the base image as 1654)USER $APP_UID
COPY --from=build /app/publish .
# Expose port 8080 (ASP.NET Core default non-root port)EXPOSE 8080
# Health check for container orchestratorsHEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "{ProjectName}.WebApi.dll"]3. Complete Frontend Dockerfile
# ---- deps stage ----FROM node:22-alpine AS depsWORKDIR /repo
# Install pnpmRUN corepack enable pnpm
# Copy lockfile and workspace configCOPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./COPY apps/web/package.json ./apps/web/COPY packages/api-types/package.json ./packages/api-types/COPY packages/api-client/package.json ./packages/api-client/
# Install dependencies using lockfile (no mutation)RUN pnpm install --frozen-lockfile
# ---- build stage ----FROM node:22-alpine AS builderWORKDIR /repo
RUN corepack enable pnpm
COPY --from=deps /repo/node_modules ./node_modulesCOPY --from=deps /repo/apps/web/node_modules ./apps/web/node_modulesCOPY . .
# Build-time env vars for NEXT_PUBLIC_ variables only.# Server-side variables are injected at runtime.ARG NEXT_PUBLIC_APP_URLENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
RUN pnpm --filter @myproject/web build
# ---- runtime stage ----FROM node:22-alpine AS runtimeWORKDIR /app
ENV NODE_ENV=productionENV PORT=3000
# Use non-root userRUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs
# Copy standalone outputCOPY --from=builder --chown=nextjs:nodejs /repo/apps/web/.next/standalone ./COPY --from=builder --chown=nextjs:nodejs /repo/apps/web/.next/static ./apps/web/.next/staticCOPY --from=builder --chown=nextjs:nodejs /repo/apps/web/public ./apps/web/public
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD ["node", "apps/web/server.js"]The frontend Dockerfile requires output: "standalone" in next.config.ts:
const nextConfig: NextConfig = { output: "standalone",}4. .dockerignore
**/.git**/.gitignore**/node_modules**/.next**/dist**/bin**/obj**/.vs**/.vscode**/TestResults**/coverage**/*.user**/*.suoPlace .dockerignore at the repository root. Docker build context is the repository root for both Dockerfiles.
5. Health Checks
- WebApi containers MUST implement
/health(or project-configured path) for orchestrator probes. - Worker containers MUST expose a health endpoint or process heartbeat documented in the project ADR.
- Both images include a
HEALTHCHECKinstruction so container orchestrators can determine readiness without an external probe configuration.
6. Image Labels
Add standard OCI labels to every runtime image:
LABEL org.opencontainers.image.title="{ProjectName}"LABEL org.opencontainers.image.source="https://github.com/your-org/{ProjectName}"LABEL org.opencontainers.image.revision="${GIT_SHA}"LABEL org.opencontainers.image.created="${BUILD_TIMESTAMP}"Pass GIT_SHA and BUILD_TIMESTAMP as build args from the CI pipeline.
7. Promotion
The same image digest that passes staging MUST be promoted to production. Do not rebuild between staging and production. See docs/conventions/backend/deployment-and-migrations.md.
8. Runtime Environment Variables
Runtime variables are injected at deploy time by Docker Compose, systemd, or the host environment. They are never in the image. Use the __ separator for nested ASP.NET Core sections:
ConnectionStrings__Database=Host=...;Database=...JwtSettings__Secret=...JwtSettings__Issuer=https://yourdomain.comFor Next.js, server-side variables are injected at runtime (standalone mode). NEXT_PUBLIC_* variables are baked in at build time.
9. No Secrets in Build Args
Build args appear in the image manifest and in docker history. MUST NOT pass secrets, connection strings, or API keys as build args.
# BAD: secret in build argARG DATABASE_PASSWORDENV ConnectionStrings__Database=Host=db;Password=${DATABASE_PASSWORD}
# GOOD: empty in image, injected at runtime by orchestratorENV ConnectionStrings__Database=""