Gå till innehåll
22 min läsning

Docker för Node.js: Det produktionsklara upplägget ingen pratar om

Multi-stage builds, non-root-användare, hälsokontroller, hemlighetshantering och optimering av imagestorlek. Docker-mönstren jag använder för varje Node.js-produktionsdeploy.

Dela:X / TwitterLinkedIn

De flesta Node.js Dockerfiler i produktion är dåliga. Inte "lite suboptimala" dåliga. Jag menar att köra som root, leverera 600MB-images med devDependencies inbakade, inga hälsokontroller, och hemligheter hårdkodade i miljövariabler som vem som helst med docker inspect kan läsa.

Jag vet det för jag skrev de Dockerfilerna. I åratal. De fungerade, så jag ifrågasatte dem aldrig. Sen en dag flaggade en säkerhetsgranskning vår container som kördes som PID 1 root med skrivåtkomst till hela filsystemet, och jag insåg att "fungerar" och "produktionsklart" är väldigt olika ribbanivåer.

Det här är Docker-upplägget jag nu använder för varje Node.js-projekt. Det är inte teoretiskt. Det kör tjänsterna bakom den här sajten och flera andra jag underhåller. Varje mönster finns här för att jag antingen blev bränd av alternativet eller såg någon annan bli bränd.

Varför din nuvarande Dockerfile förmodligen är fel#

Låt mig gissa hur din Dockerfile ser ut:

dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]

Det här är "hello world"-versionen av Dockerfiler. Den fungerar. Den har också minst fem problem som kommer att skada dig i produktion.

Körning som Root#

Som standard kör node Docker-imagen som root. Det betyder att din applikationsprocess har root-privilegier inuti containern. Om någon utnyttjar en sårbarhet i din app — en path traversal-bugg, en SSRF, ett beroende med bakdörr — har de root-åtkomst till containerns filsystem, kan modifiera binärer, installera paket, och potentiellt eskalera vidare beroende på din container runtime-konfiguration.

"Men containrar är isolerade!" Delvis. Container-utbrytningar är verkliga. CVE-2024-21626, CVE-2019-5736 — det är verkliga container-utbrytningar. Att köra som icke-root är en försvarsdjupåtgärd. Det kostar ingenting och stänger en hel klass av attacker.

Installera devDependencies i produktion#

npm install utan flaggor installerar allt. Dina testramverk, linters, byggverktyg, typkontrollerare — allt sitter i din produktionsimage. Det blåser upp din image med hundratals megabyte och ökar din attackyta. Varje ytterligare paket är en potentiell sårbarhet som Trivy eller Snyk kommer att flagga.

COPY av allt#

COPY . . kopierar hela din projektkatalog till imagen. Det inkluderar .git (som kan vara enorm), .env-filer (som innehåller hemligheter), node_modules (som du ändå ska installera om), testfiler, dokumentation, CI-konfigurationer — allt.

Inga hälsokontroller#

Utan en HEALTHCHECK-instruktion har Docker ingen aning om din applikation faktiskt serverar trafik. Processen kan köra men vara deadlockad, slut på minne, eller fast i en oändlig loop. Docker kommer att rapportera containern som "running" för att processen inte har avslutats. Din lastbalanserare fortsätter skicka trafik till en zombiecontainer.

Ingen strategi för lagercachning#

Att kopiera allt före beroendeinstallation innebär att en ändring av en enda rad källkod ogiltigförklarar npm install-cachen. Varje build installerar alla beroenden från grunden. På ett projekt med tunga beroenden är det 2-3 minuters bortkastad tid per build.

Låt oss fixa allt detta.

Multi-Stage Builds: Den enskilt största vinsten#

Multi-stage builds är den mest påverkande förändringen du kan göra i en Node.js Dockerfile. Konceptet är enkelt: använd ett steg för att bygga din applikation, kopiera sedan bara de artefakter du behöver till en ren, minimal slutimage.

Här är skillnaden i praktiken:

dockerfile
# Single stage: ~600MB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
 
# Multi-stage: ~150MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]

Builder-steget har allt: fullständig Node.js, npm, byggverktyg, källkod, devDependencies. Runner-steget har bara det som behövs vid körtid. Builder-steget kasseras helt — det hamnar inte i den slutliga imagen.

Riktiga storleksjämförelser#

Jag mätte dessa på ett faktiskt Express.js API-projekt med ungefär 40 beroenden:

TillvägagångssättImagestorlek
node:20 + npm install1.1 GB
node:20-slim + npm install420 MB
node:20-alpine + npm ci280 MB
Multi-stage + alpine + enbart produktionsberoenden150 MB
Multi-stage + alpine + rensade beroenden95 MB

Det är en 10x-minskning från det naiva tillvägagångssättet. Mindre images innebär snabbare pulls, snabbare deploys och mindre attackyta.

Varför Alpine?#

Alpine Linux använder musl libc istället för glibc, och inkluderar inte pakethanterarcache, dokumentation, eller de flesta verktyg du hittar i en standarddistribution av Linux. Basimagen node:20-alpine är cirka 50MB jämfört med 350MB för node:20-slim och över 1GB för fullständig node:20.

Kompromissen är att vissa npm-paket med nativa bindningar (som bcrypt, sharp, canvas) behöver kompileras mot musl. I de flesta fall fungerar det bara — npm laddar ner rätt förbyggd binär. Om du stöter på problem kan du installera byggberoenden i builder-steget:

dockerfile
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... resten av bygget

Dessa byggverktyg finns bara i builder-steget. De är inte i din slutliga image.

Den kompletta produktions-Dockerfilen#

Här är Dockerfilen jag använder som utgångspunkt för varje Node.js-projekt. Varje rad är avsiktlig.

dockerfile
# ============================================
# Steg 1: Installera beroenden
# ============================================
FROM node:20-alpine AS deps
 
# Säkerhet: skapa en arbetskatalog före allt annat
WORKDIR /app
 
# Installera beroenden baserat på lockfile
# Kopiera BARA paketfiler först — detta är avgörande för lagercachning
COPY package.json package-lock.json ./
 
# ci är bättre än install: det är snabbare, striktare och reproducerbart
# --omit=dev utesluter devDependencies från detta steg
RUN npm ci --omit=dev
 
# ============================================
# Steg 2: Bygg applikationen
# ============================================
FROM node:20-alpine AS builder
 
WORKDIR /app
 
# Kopiera paketfiler och installera ALLA beroenden (inklusive dev)
COPY package.json package-lock.json ./
RUN npm ci
 
# Kopiera NU källkoden — ändringar här ogiltigförklarar inte npm ci-cachen
COPY . .
 
# Bygg applikationen (TypeScript-kompilering, Next.js-build, etc.)
RUN npm run build
 
# ============================================
# Steg 3: Produktionsrunner
# ============================================
FROM node:20-alpine AS runner
 
# Lägg till etiketter för imagemetadata
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
 
# Säkerhet: installera dumb-init för korrekt PID 1-signalhantering
RUN apk add --no-cache dumb-init
 
# Säkerhet: ställ in NODE_ENV före allt annat
ENV NODE_ENV=production
 
# Säkerhet: använd icke-root-användare
# Node-imagen inkluderar redan en 'node'-användare (uid 1000)
USER node
 
# Skapa appkatalog ägd av node-användaren
WORKDIR /app
 
# Kopiera produktionsberoenden från deps-steget
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
 
# Kopiera byggd applikation från builder-steget
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
 
# Exponera porten (enbart dokumentation — publicerar den inte)
EXPOSE 3000
 
# Hälsokontroll: curl finns inte i alpine, använd node
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
 
# Använd dumb-init som PID 1 för att hantera signaler korrekt
ENTRYPOINT ["dumb-init", "--"]
 
# Starta applikationen
CMD ["node", "dist/server.js"]

Låt mig förklara de delar som inte är uppenbara.

Varför tre steg istället för två?#

deps-steget installerar bara produktionsberoenden. builder-steget installerar allt (inklusive devDependencies) och bygger appen. runner-steget kopierar produktionsberoenden från deps och byggd kod från builder.

Varför inte installera produktionsberoenden i builder-steget? Eftersom builder-steget har devDependencies inblandade. Du skulle behöva köra npm prune --production efter bygget, vilket är långsammare och mindre tillförlitligt än en ren produktionsberoendeinstallation.

Varför dumb-init?#

När du kör node server.js i en container blir Node.js PID 1. PID 1 har speciellt beteende i Linux: den tar inte emot standardsignalhanterare. Om du skickar SIGTERM till containern (vilket är vad docker stop gör), kommer Node.js som PID 1 inte att hantera det som standard. Docker väntar 10 sekunder, skickar sedan SIGKILL, som omedelbart avslutar processen utan någon rensning — ingen graciös nedstängning, ingen stängning av databasanslutningar, ingen avslutning av pågående förfrågningar.

dumb-init agerar som PID 1 och vidarebefordrar signaler korrekt till din applikation. Din Node.js-process tar emot SIGTERM som förväntat och kan stängas ner graciöst:

javascript
// server.js
const server = app.listen(3000);
 
process.on('SIGTERM', () => {
  console.log('SIGTERM mottaget, stänger ner graciöst');
  server.close(() => {
    console.log('HTTP-server stängd');
    // Stäng databasanslutningar, tömma loggar, etc.
    process.exit(0);
  });
});

Ett alternativ är --init-flaggan i docker run, men att baka in den i imagen innebär att det fungerar oavsett hur containern startas.

.dockerignore-filen#

Denna är lika viktig som Dockerfilen själv. Utan den skickar COPY . . allt till Docker-demonen:

# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
.dockerignore
README.md
LICENSE
.github
.vscode
.idea
coverage
.nyc_output
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__
test
tests
docs
.husky
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
vitest.config.*

Varje fil i .dockerignore är en fil som inte skickas till byggkontexten, inte hamnar i din image och inte ogiltigförklarar din lagercache vid ändring.

Lagercachning: Sluta vänta 3 minuter per build#

Docker bygger images i lager. Varje instruktion skapar ett lager. Om ett lager inte har ändrats använder Docker den cachade versionen. Men här är den kritiska detaljen: om ett lager ändras ogiltigförklaras alla efterföljande lager.

Det är därför ordningen på instruktioner spelar enorm roll.

Fel ordning#

dockerfile
COPY . .
RUN npm ci

Varje gång du ändrar någon fil — en enda rad i en enda källfil — ser Docker att COPY . .-lagret ändrades. Det ogiltigförklarar det lagret och allt efter det, inklusive npm ci. Du installerar om alla beroenden vid varje kodändring.

Rätt ordning#

dockerfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Nu körs npm ci bara när package.json eller package-lock.json ändras. Om du bara ändrade källkod återanvänder Docker det cachade npm ci-lagret. På ett projekt med 500+ beroenden sparar detta 60-120 sekunder per build.

Cachemount för npm#

Docker BuildKit stöder cachemounts som bevarar npm-cachen mellan builds:

dockerfile
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

Detta behåller npm:s nedladdningscache mellan builds. Om ett beroende redan laddats ner i en tidigare build använder npm den cachade versionen istället för att ladda ner den igen. Detta är särskilt användbart i CI där du bygger ofta.

För att använda BuildKit, ställ in miljövariabeln:

bash
DOCKER_BUILDKIT=1 docker build -t myapp .

Eller lägg till i din Docker daemon-konfiguration:

json
{
  "features": {
    "buildkit": true
  }
}

Använda ARG för cache-busting#

Ibland behöver du tvinga ett lager att byggas om. Till exempel om du hämtar en latest-tagg från ett register och vill säkerställa att du får den senaste versionen:

dockerfile
ARG CACHE_BUST=1
RUN npm ci

Bygg med ett unikt värde för att bryta cachen:

bash
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .

Använd detta sparsamt. Hela poängen med cachning är hastighet — bryt bara cachen när du har en anledning.

Hemlighetshantering: Sluta lägga hemligheter i din Dockerfile#

Det här är ett av de vanligaste och farligaste misstagen. Jag ser det konstant:

dockerfile
# GÖR ALDRIG DETTA
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456

Miljövariabler satta med ENV i en Dockerfile bakas in i imagen. Vem som helst som hämtar imagen kan se dem med docker inspect eller docker history. De är också synliga i varje lager efter att de ställts in. Även om du avställer dem senare finns de i lagerhistoriken.

De tre nivåerna av hemligheter#

1. Byggtidshemligheter (Docker BuildKit)

Om du behöver hemligheter under bygget (som en privat npm-registernyckel), använd BuildKits --secret-flagga:

dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
 
# Montera hemligheten vid byggtid — den lagras aldrig i imagen
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
    npm ci
 
COPY . .
RUN npm run build

Bygg med:

bash
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

.npmrc-filen är tillgänglig under RUN-kommandot men committas aldrig till något imagelager. Den visas inte i docker history eller docker inspect.

2. Körtidshemligheter via miljövariabler

För hemligheter din applikation behöver vid körtid, skicka dem när containern startas:

bash
docker run -d \
  -e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
  -e API_KEY="sk-live-abc123" \
  myapp

Eller med en env-fil:

bash
docker run -d --env-file .env.production myapp

Dessa är synliga via docker inspect på den körande containern, men de är inte inbakade i imagen. Den som hämtar imagen får inte hemligheterna.

3. Docker-hemligheter (Swarm / Kubernetes)

För korrekt hemlighetshantering i orkestrerade miljöer:

yaml
# docker-compose.yml (Swarm-läge)
version: "3.8"
services:
  api:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
 
secrets:
  db_password:
    external: true
  api_key:
    external: true

Docker monterar hemligheter som filer vid /run/secrets/<hemligt_namn>. Din applikation läser dem från filsystemet:

javascript
import { readFileSync } from "fs";
 
function getSecret(name) {
  try {
    return readFileSync(`/run/secrets/${name}`, "utf8").trim();
  } catch {
    // Falla tillbaka till miljövariabel för lokal utveckling
    return process.env[name.toUpperCase()];
  }
}
 
const dbPassword = getSecret("db_password");

Det här är den säkraste metoden eftersom hemligheter aldrig visas i miljövariabler, processlistor eller containerinspektionsutdata.

.env-filer och Docker#

Inkludera aldrig .env-filer i din Docker-image. Din .dockerignore bör utesluta dem (vilket är anledningen till att vi listade .env och .env.* tidigare). För lokal utveckling med docker-compose, montera dem vid körtid:

yaml
services:
  api:
    env_file:
      - .env.local

Hälsokontroller: Låt Docker veta att din app faktiskt fungerar#

En hälsokontroll berättar för Docker om din applikation fungerar korrekt. Utan en vet Docker bara om processen körs — inte om den faktiskt kan hantera förfrågningar.

HEALTHCHECK-instruktionen#

dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"

Låt mig bryta ner parametrarna:

  • --interval=30s: Kontrollera var 30:e sekund
  • --timeout=10s: Om kontrollen tar längre än 10 sekunder, betrakta det som misslyckat
  • --start-period=40s: Ge appen 40 sekunder att starta innan misslyckanden räknas
  • --retries=3: Markera som ohälsosam efter 3 misslyckanden i rad

Varför inte använda curl?#

Alpine inkluderar inte curl som standard. Du kan installera det (apk add --no-cache curl), men det lägger till ytterligare en binär till din minimala image. Att använda Node.js direkt innebär noll ytterligare beroenden.

För ännu lättare hälsokontroller kan du använda ett dedikerat skript:

javascript
// healthcheck.js
const http = require("http");
 
const options = {
  hostname: "localhost",
  port: 3000,
  path: "/health",
  timeout: 5000,
};
 
const req = http.request(options, (res) => {
  process.exit(res.statusCode === 200 ? 0 : 1);
});
 
req.on("error", () => process.exit(1));
req.on("timeout", () => {
  req.destroy();
  process.exit(1);
});
 
req.end();
dockerfile
COPY --chown=node:node healthcheck.js ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD ["node", "healthcheck.js"]

Hälsoendpointen#

Din applikation behöver en /health-endpoint för kontrollen att anropa. Returnera inte bara 200 — verifiera faktiskt att din app är frisk:

javascript
app.get("/health", async (req, res) => {
  const checks = {
    uptime: process.uptime(),
    timestamp: Date.now(),
    status: "ok",
  };
 
  try {
    // Kontrollera databasanslutning
    await db.query("SELECT 1");
    checks.database = "connected";
  } catch (err) {
    checks.database = "disconnected";
    checks.status = "degraded";
  }
 
  try {
    // Kontrollera Redis-anslutning
    await redis.ping();
    checks.redis = "connected";
  } catch (err) {
    checks.redis = "disconnected";
    checks.status = "degraded";
  }
 
  const statusCode = checks.status === "ok" ? 200 : 503;
  res.status(statusCode).json(checks);
});

En "degraded"-status med en 503 säger åt orkestreraren att sluta dirigera trafik till denna instans medan den återhämtar sig, men utlöser inte nödvändigtvis en omstart.

Varför hälsokontroller spelar roll för orkestrerare#

Docker Swarm, Kubernetes och även vanlig docker-compose med restart: always använder hälsokontroller för att fatta beslut:

  • Lastbalanserare slutar skicka trafik till ohälsosamma containrar
  • Rullande uppdateringar väntar tills den nya containern är frisk innan den gamla stoppas
  • Orkestrerare kan starta om containrar som blir ohälsosamma
  • Deploy-pipelines kan verifiera att en deploy lyckades

Utan hälsokontroller kan en rullande deploy döda den gamla containern innan den nya är redo, vilket orsakar driftstopp.

docker-compose för utveckling#

Din utvecklingsmiljö bör vara så nära produktion som möjligt, men med bekvämligheten av hot reload, debuggers och omedelbar feedback. Här är docker-compose-upplägget jag använder för utveckling:

yaml
# docker-compose.dev.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
      args:
        NODE_VERSION: "20"
    ports:
      - "3000:3000"
      - "9229:9229"   # Node.js debugger
    volumes:
      # Montera källkod för hot reload
      - .:/app
      # Anonym volym för att bevara node_modules från imagen
      # Detta hindrar värdens node_modules från att åsidosätta containerns
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:devpassword@db:5432/myapp_dev
      - REDIS_URL=redis://redis:6379
    env_file:
      - .env.local
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    command: npm run dev
 
  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: myapp_dev
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: devpassword
    volumes:
      # Namngiven volym för persistent data mellan containeromstarter
      - pgdata:/var/lib/postgresql/data
      # Initialiseringsskript
      - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data
    command: redis-server --appendonly yes
 
  # Valfritt: databasadmin-UI
  adminer:
    image: adminer:latest
    ports:
      - "8080:8080"
    depends_on:
      - db
 
volumes:
  pgdata:
  redisdata:

Viktiga utvecklingsmönster#

Volymmounts för hot reload: .:/app volymmount mappar din lokala källkod in i containern. När du sparar en fil är ändringen omedelbart synlig inuti containern. Kombinerat med en dev-server som övervakar ändringar (som nodemon eller tsx --watch) får du omedelbar feedback.

node_modules-tricket: Den anonyma volymen - /app/node_modules säkerställer att containern använder sina egna node_modules (installerade under imagebygaget) istället för värdens node_modules. Det här är kritiskt eftersom nativa moduler kompilerade på macOS inte fungerar inuti en Linux-container.

Tjänstberoenden: depends_on med condition: service_healthy säkerställer att databasen faktiskt är redo innan din app försöker ansluta. Utan hälsokontrollvillkoret väntar depends_on bara tills containern startar — inte tills tjänsten inuti den är redo.

Namngivna volymer: pgdata och redisdata bevaras mellan containeromstarter. Utan namngivna volymer förlorar du din databas varje gång du kör docker-compose down.

Utvecklings-Dockerfilen#

Din utvecklings-Dockerfile är enklare än produktionens:

dockerfile
# Dockerfile.dev
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
 
WORKDIR /app
 
# Installera alla beroenden (inklusive devDependencies)
COPY package*.json ./
RUN npm ci
 
# Källkod monteras via volym, inte kopierad
# Men vi behöver den fortfarande för den initiala bygget
COPY . .
 
EXPOSE 3000 9229
 
CMD ["npm", "run", "dev"]

Ingen multi-stage build, ingen produktionsoptimering. Målet är snabb iteration, inte små images.

Produktions Docker Compose#

Produktions docker-compose är en annan sak. Här är vad jag använder:

yaml
# docker-compose.prod.yml
services:
  app:
    image: ghcr.io/yourorg/myapp:${TAG:-latest}
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    env_file:
      - .env.production
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
      replicas: 2
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
      interval: 30s
      timeout: 10s
      start_period: 40s
      retries: 3
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    networks:
      - internal
      - web
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
 
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 1G
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
 
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: >
      redis-server
      --appendonly yes
      --maxmemory 256mb
      --maxmemory-policy allkeys-lru
    volumes:
      - redisdata:/data
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M
    networks:
      - internal
    logging:
      driver: "json-file"
      options:
        max-size: "5m"
        max-file: "3"
 
volumes:
  pgdata:
    driver: local
  redisdata:
    driver: local
 
networks:
  internal:
    driver: bridge
  web:
    external: true

Vad som skiljer sig från utveckling#

Omstartspolicy: unless-stopped startar om containern automatiskt om den kraschar, såvida du inte explicit stoppade den. Detta hanterar scenariot "kraschar klockan 3 på natten". Alternativet always skulle också starta om containrar du avsiktligt stoppade, vilket vanligtvis inte är vad du vill.

Resursbegränsningar: Utan begränsningar kommer en minnesläcka i din Node.js-app att förbruka allt tillgängligt RAM på värden, och potentiellt döda andra containrar eller själva värden. Ställ in begränsningar baserat på din applikations faktiska användning plus lite marginal:

bash
# Övervaka faktisk användning för att ställa in lämpliga begränsningar
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"

Loggkonfiguration: Utan max-size och max-file växer Docker-loggar obegränsat. Jag har sett produktionsservrar ta slut på diskutrymme på grund av Docker-loggar. json-file med rotation är den enklaste lösningen. För centraliserad loggning, byt till fluentd- eller gelf-drivrutinen:

yaml
logging:
  driver: "fluentd"
  options:
    fluentd-address: "localhost:24224"
    tag: "myapp.{{.Name}}"

Nätverksisolering: Det internal-nätverket är bara tillgängligt för tjänster i denna compose-stack. Databasen och Redis är inte exponerade mot värden eller andra containrar. Bara app-tjänsten är ansluten till web-nätverket, som din reverse proxy (Nginx, Traefik) använder för att dirigera trafik.

Ingen portmappning för databaser: Märk att db och redis inte har ports i produktionskonfigurationen. De är bara tillgängliga via det interna Docker-nätverket. I utveckling exponerar vi dem så att vi kan använda lokala verktyg (pgAdmin, Redis Insight). I produktion finns det ingen anledning för dem att vara tillgängliga utifrån Docker-nätverket.

Next.js-specifikt: Standalone-output#

Next.js har en inbyggd Docker-optimering som många inte känner till: standalone output-läge. Det spårar din applikations importer och kopierar bara de filer som behövs för att köra — inga node_modules krävs (beroenden buntas).

Aktivera det i next.config.ts:

typescript
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  output: "standalone",
};
 
export default nextConfig;

Detta förändrar byggutdata dramatiskt. Istället för att behöva hela node_modules-katalogen producerar Next.js en fristående server.js i .next/standalone/ som bara inkluderar de beroenden den faktiskt använder.

Next.js produktions-Dockerfile#

Det här är Dockerfilen jag använder för Next.js-projekt, baserad på det officiella Vercel-exemplet men med säkerhetsförstärkning:

dockerfile
# ============================================
# Steg 1: Installera beroenden
# ============================================
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
 
COPY package.json package-lock.json ./
RUN npm ci
 
# ============================================
# Steg 2: Bygg applikationen
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
 
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
# Inaktivera Next.js telemetri under bygget
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN npm run build
 
# ============================================
# Steg 3: Produktionsrunner
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
 
RUN apk add --no-cache dumb-init
 
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
# Icke-root-användare
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
# Kopiera publika tillgångar
COPY --from=builder /app/public ./public
 
# Konfigurera standalone output-katalogen
# Utnyttjar automatiskt output traces för att reducera imagestorlek
# https://nextjs.org/docs/advanced-features/output-file-tracing
RUN mkdir .next
RUN chown nextjs:nodejs .next
 
# Kopiera standalone-servern och statiska filer
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
 
EXPOSE 3000
 
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
 
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
 
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

Storleksjämförelse för Next.js#

TillvägagångssättImagestorlek
node:20 + fullständig node_modules + .next1.4 GB
node:20-alpine + fullständig node_modules + .next600 MB
node:20-alpine + standalone output120 MB

Standalone-output är transformativt. En 1.4 GB image blir 120 MB. Deploys som tog 90 sekunder att hämta tar nu 10 sekunder.

Hantering av statiska filer#

Next.js standalone-läge inkluderar inte public-mappen eller de statiska tillgångarna från .next/static. Du behöver kopiera dem explicit (vilket vi gör i Dockerfilen ovan). I produktion vill du vanligtvis ha ett CDN framför dessa:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  output: "standalone",
  assetPrefix: process.env.CDN_URL || undefined,
};

Om du inte använder ett CDN serverar Next.js statiska filer direkt. Standalone-servern hanterar detta utan problem — du behöver bara se till att filerna är på rätt plats (vilket vår Dockerfile säkerställer).

Sharp för bildoptimering#

Next.js använder sharp för bildoptimering. I den Alpine-baserade produktionsimagen behöver du se till att rätt binär är tillgänglig:

dockerfile
# I runner-steget, innan du byter till icke-root-användare
RUN apk add --no-cache --virtual .sharp-deps vips-dev

Eller bättre, installera det som ett produktionsberoende och låt npm hantera den plattformsspecifika binären:

bash
npm install sharp

node:20-alpine-imagen fungerar med sharps förbyggda linux-x64-musl-binär. Ingen speciell konfiguration behövs i de flesta fall.

Imagescanning och säkerhet#

Att bygga en liten image med en icke-root-användare är en bra start, men det räcker inte för seriösa produktionsarbetsbelastningar. Här är hur du går längre.

Trivy: Skanna dina images#

Trivy är en omfattande sårbarhetsskanner för containerimages. Kör den i din CI-pipeline:

bash
# Installera trivy
brew install aquasecurity/trivy/trivy  # macOS
# eller
apt-get install trivy  # Debian/Ubuntu
 
# Skanna din image
trivy image myapp:latest

Exempelutdata:

myapp:latest (alpine 3.19.1)
=============================
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

Node.js (node_modules/package-lock.json)
=========================================
Total: 2 (UNKNOWN: 0, LOW: 0, MEDIUM: 1, HIGH: 1, CRITICAL: 0)

┌──────────────┬────────────────┬──────────┬────────┬───────────────┐
│   Library    │ Vulnerability  │ Severity │ Status │ Fixed Version │
├──────────────┼────────────────┼──────────┼────────┼───────────────┤
│ semver       │ CVE-2022-25883 │ HIGH     │ fixed  │ 7.5.4         │
│ word-wrap    │ CVE-2023-26115 │ MEDIUM   │ fixed  │ 1.2.4         │
└──────────────┴────────────────┴──────────┴────────┴───────────────┘

Integrera det i CI för att misslycka builds vid kritiska sårbarheter:

yaml
# .github/workflows/docker.yml
- name: Build image
  run: docker build -t myapp:${{ github.sha }} .
 
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    exit-code: 1
    severity: CRITICAL,HIGH
    ignore-unfixed: true

Skrivskyddat filsystem#

Du kan köra containrar med ett skrivskyddat rotfilsystem. Detta hindrar en angripare från att modifiera binärer, installera verktyg eller skriva skadliga skript:

bash
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /app/.next/cache \
  myapp:latest

--tmpfs-monteringarna ger skrivbara temporära kataloger där din applikation legitimt behöver skriva (temporära filer, cachar). Allt annat är skrivskyddat.

I docker-compose:

yaml
services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /app/.next/cache

Ta bort alla kapabiliteter#

Linux-kapabiliteter är finmaskiga behörigheter som ersätter allt-eller-inget root-modellen. Som standard får Docker-containrar en delmängd av kapabiliteter. Du kan ta bort alla:

bash
docker run --cap-drop=ALL myapp:latest

Om din applikation behöver binda till en port under 1024 behöver du NET_BIND_SERVICE. Men eftersom vi använder port 3000 med en icke-root-användare behöver vi inga kapabiliteter:

yaml
services:
  app:
    image: myapp:latest
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

no-new-privileges hindrar processen från att få ytterligare privilegier genom setuid/setgid-binärer. Det är en försvarsdjupåtgärd som inte kostar något.

Pinningning av basimage-digest#

Istället för att använda node:20-alpine (som är ett rörligt mål), pinna till en specifik digest:

dockerfile
FROM node:20-alpine@sha256:abcdef123456...

Hämta digesten med:

bash
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpine

Detta säkerställer att dina builds är 100% reproducerbara. Kompromissen är att du inte automatiskt får säkerhetspatchar till basimagen. Använd Dependabot eller Renovate för att automatisera digest-uppdateringar:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: docker
    directory: "/"
    schedule:
      interval: weekly

CI/CD-integration: Allt sammanslaget#

Här är ett komplett GitHub Actions-arbetsflöde som bygger, skannar och pushar en Docker-image:

yaml
# .github/workflows/docker.yml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      security-events: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha
            type=ref,event=branch
            type=semver,pattern={{version}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          exit-code: 1
          severity: CRITICAL,HIGH
          ignore-unfixed: true
 
      - name: Upload Trivy results
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

BuildKit-cache i CI#

Raderna cache-from: type=gha och cache-to: type=gha,mode=max använder GitHub Actions cache som Docker-lagercache. Det innebär att dina CI-builds drar nytta av lagercachning mellan körningar. Första bygget tar 5 minuter; efterföljande builds med enbart kodändringar tar 30 sekunder.

Vanliga fallgropar och hur du undviker dem#

node_modules inuti imagen vs värdkonflikten#

Om du volymmonterar din projektkatalog in i en container (-v .:/app) åsidosätter värdens node_modules containerns. Nativa moduler kompilerade på macOS fungerar inte i Linux. Använd alltid tricket med anonym volym:

yaml
volumes:
  - .:/app
  - /app/node_modules  # bevarar containerns node_modules

SIGTERM-hantering i TypeScript-projekt#

Om du kör TypeScript med tsx eller ts-node i utveckling fungerar signalhantering normalt. Men i produktion, om du använder den kompilerade JavaScripten med node, se till att din kompilerade utdata bevarar signalhanterarna. Vissa byggverktyg optimerar bort "oanvänd" kod.

Minnesbegränsningar och Node.js#

Node.js respekterar inte automatiskt Dockers minnesbegränsningar. Om din container har en 512MB minnesbegränsning kommer Node.js fortfarande att försöka använda sin standardheapstorlek (cirka 1.5 GB på 64-bitarssystem). Ställ in max old space size:

dockerfile
CMD ["node", "--max-old-space-size=384", "dist/server.js"]

Lämna cirka 25% marginal mellan Node.js heapbegränsning och containerns minnesbegränsning för icke-heapminne (buffertar, nativ kod, etc.).

Eller använd flaggan för automatisk detektering:

dockerfile
ENV NODE_OPTIONS="--max-old-space-size=384"

Tidszonsprobelm#

Alpine använder UTC som standard. Om din applikation beror på en specifik tidszon:

dockerfile
RUN apk add --no-cache tzdata
ENV TZ=America/New_York

Men bättre: skriv tidszonsoberoende kod. Lagra allt i UTC. Konvertera till lokal tid bara i presentationslagret.

Byggargument vs miljövariabler#

  • ARG är tillgängligt bara under bygget. Det kvarstår inte i den slutliga imagen (om du inte kopierar det till ENV).
  • ENV kvarstår i imagen och är tillgängligt vid körtid.
dockerfile
# Byggtidskonfiguration
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
 
# Körtidskonfiguration
ENV PORT=3000
 
# FEL: Detta gör hemligheten synlig i imagen
ARG API_KEY
ENV API_KEY=${API_KEY}
 
# RÄTT: Skicka hemligheter vid körtid
# docker run -e API_KEY=secret myapp

Övervakning i produktion#

Ditt Docker-upplägg är inte komplett utan observerbarhet. Här är en minimal men effektiv övervakningsstack:

yaml
# docker-compose.monitoring.yml
services:
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    ports:
      - "9090:9090"
    networks:
      - internal
 
  grafana:
    image: grafana/grafana:latest
    volumes:
      - grafana_data:/var/lib/grafana
    ports:
      - "3001:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
    networks:
      - internal
 
volumes:
  prometheus_data:
  grafana_data:

Exponera mätvärden från din Node.js-app med prom-client:

javascript
import { collectDefaultMetrics, Registry, Histogram } from "prom-client";
 
const register = new Registry();
collectDefaultMetrics({ register });
 
const httpRequestDuration = new Histogram({
  name: "http_request_duration_seconds",
  help: "Varaktighet för HTTP-förfrågningar i sekunder",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
  registers: [register],
});
 
// Middleware
app.use((req, res, next) => {
  const end = httpRequestDuration.startTimer();
  res.on("finish", () => {
    end({ method: req.method, route: req.route?.path || req.path, status_code: res.statusCode });
  });
  next();
});
 
// Mätvärdensendpoint
app.get("/metrics", async (req, res) => {
  res.set("Content-Type", register.contentType);
  res.end(await register.metrics());
});

Checklistan#

Innan du levererar en containeriserad Node.js-app till produktion, verifiera:

  • Icke-root-användare — Containern körs som en icke-root-användare
  • Multi-stage build — devDependencies och byggverktyg finns inte i den slutliga imagen
  • Alpine-bas — Använder en minimal basimage
  • .dockerignore.git, .env, node_modules, tester uteslutna
  • Lagercachningpackage.json kopieras före källkod
  • Hälsokontroll — HEALTHCHECK-instruktion i Dockerfilen
  • Signalhanteringdumb-init eller --init för korrekt SIGTERM-hantering
  • Inga hemligheter i imagen — Inga ENV med känsliga värden i Dockerfilen
  • Resursbegränsningar — Minnes- och CPU-begränsningar satta i compose/orkestrerare
  • Loggrotation — Loggdrivrutin konfigurerad med maxstorlek
  • Imagescanning — Trivy eller motsvarande i CI-pipeline
  • Pinnade versioner — Basimage- och beroendeversioner pinnade
  • Minnesbegränsningar--max-old-space-size satt för Node.js heap

De flesta av dessa är engångsinställningar. Gör det en gång, skapa en mall, och varje nytt projekt startar med en produktionsklar container från dag ett.

Docker är inte komplicerat. Men gapet mellan en "fungerande" Dockerfile och en produktionsklar är bredare än de flesta tror. Mönstren i den här guiden stänger det gapet. Använd dem, anpassa dem, och sluta deploya root-containrar med 1GB images och inga hälsokontroller. Ditt framtida jag — det som blir uppringt klockan 3 på natten — kommer att tacka dig.

Relaterade inlägg