Containers on a budget: Docker and managed container hosts

A small production image, compose for local parity, registry and tag discipline, health checks, and what managed container platforms add.

A production image

# build stage: everything needed to compile, thrown away
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# runtime stage: only what is needed to run
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /src/dist ./dist
COPY --from=build --chown=app:app /src/node_modules ./node_modules
COPY --from=build --chown=app:app /src/package.json ./

USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1

CMD ["node", "dist/server.js"]
  • Copy the lockfile and install before copying the source, so a code change does not invalidate the dependency layer.
  • Run as a non-root user. It costs one line and removes a whole class of container escape.
  • The final image should contain no compiler, no package manager cache and no source. Smaller images pull faster and have fewer things to patch.
  • Never bake configuration into the image. An image should be identical across environments and configured at run time.

Local parity with compose

services:
  app:
    build: .
    ports: ["3000:3000"]
    environment:
      DATABASE_URL: postgres://app:app@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - ./src:/app/src        # development only, never in production

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 10

  cache:
    image: redis:7-alpine

volumes:
  pgdata:

Compose is for parity, not for production. Its job is to make the local environment match the deployed one closely enough that an environment-specific bug is rare, and to make the dependency versions explicit.

Managed container hosts and tag discipline

What the platform addsWhy it mattersWhat it does not do
Scheduling across nodesSurvives a node failureKeep your data safe
Rolling deploysNo downtime on releaseTest the new version for you
Health-check driven restartsReplaces a stuck processDetect a process that is up but wrong
Log aggregationOne place to lookTell you which log line matters
Secrets injectionValues never in the imageRotate them for you
AutoscalingHandles a traffic spikeFix a slow query
# immutable tags: the commit sha, not "latest"
docker build -t registry.example/app:$(git rev-parse --short HEAD) .
docker push registry.example/app:$(git rev-parse --short HEAD)

# deploy the exact tag that was tested
kubectl set image deployment/app app=registry.example/app:a1b2c3d --record

# and keep a staging tag pointing at the same digest
docker tag registry.example/app:a1b2c3d registry.example/app:staging
  1. Tag every image with the commit it was built from, and deploy that tag. A mutable tag makes a rollback ambiguous.
  2. latest is fine for local development and unacceptable in production: two machines pulling it can get different images.
  3. A health check should test dependencies, not just that the process is listening. A container that answers 200 while the database is unreachable is worse than one that fails.
  4. Set resource requests and limits. Without them a single container can starve its neighbours, and scheduling decisions become arbitrary.
💡
A container is not a security boundary. It shares a kernel with everything else on the node, and a host-mounted socket or a privileged flag removes the isolation entirely. Treat the image as a packaging format, and apply the same patching and least-privilege discipline you would to a bare process.

FAQ

Should I use Kubernetes?
Only when you have several services, a team to operate it, and a requirement it uniquely meets. A managed container host with a single service is usually the better trade for a small team.
How large should an image be?
Small enough that a pull is fast on a cold node. A multi-stage Node image is typically 100-200 MB; needing more than a gigabyte usually means build tools leaked into the runtime stage.

Build and deploy workflows Scaling: load balancing, autoscaling and stateless design

Last refreshed 2026-09-18.