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 adds | Why it matters | What it does not do |
|---|---|---|
| Scheduling across nodes | Survives a node failure | Keep your data safe |
| Rolling deploys | No downtime on release | Test the new version for you |
| Health-check driven restarts | Replaces a stuck process | Detect a process that is up but wrong |
| Log aggregation | One place to look | Tell you which log line matters |
| Secrets injection | Values never in the image | Rotate them for you |
| Autoscaling | Handles a traffic spike | Fix 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- Tag every image with the commit it was built from, and deploy that tag. A mutable tag makes a rollback ambiguous.
latestis fine for local development and unacceptable in production: two machines pulling it can get different images.- 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.
- 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.
Related
Build and deploy workflows Scaling: load balancing, autoscaling and stateless design
Last refreshed 2026-09-18.