Skip to content

10 · Project — Secure, Resource-Limited Production Stack

This capstone for Level 3 combines security hardening, resource limits, multi-container patterns, private registry usage, centralized logging, and secrets into one deployable stack — everything from modules 01–09.

The application

The same link-shortener from Level 2's project, hardened for production: a non-root, resource-capped API; Postgres with a managed secret instead of a plain-text password; an nginx proxy; and a log-shipping sidecar.

project/
├── stack.yml
├── nginx/default.conf
├── secrets/db_password.txt   (gitignored; created via docker secret in Swarm)
└── api/Dockerfile

Hardened API Dockerfile

# api/Dockerfile
FROM node:20 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --chown=node:node . .
USER node
ENV NODE_ENV=production
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:8000/health',r=>process.exit(r.statusCode===200?0:1))"
CMD ["node", "src/server.js"]

The stack file

# stack.yml
version: "3.9"

services:
  proxy:
    image: nginx:1.25
    ports:
      - "8080:80"
    configs:
      - source: nginx_conf
        target: /etc/nginx/conf.d/default.conf
    networks: [public]
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 128M

  api:
    image: registry.example.com/team/link-shortener:1.0.0
    environment:
      - DATABASE_URL_FILE=/run/secrets/database_url
    secrets:
      - database_url
    networks: [public, data]
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: "1.0"
          memory: 256M
        reservations:
          cpus: "0.25"
          memory: 128M
      restart_policy:
        condition: on-failure
      update_config:
        parallelism: 1
        delay: 10s

  db:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
      - POSTGRES_USER=app
      - POSTGRES_DB=links
    secrets:
      - db_password
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks: [data]
    deploy:
      resources:
        limits:
          memory: 512M

  log-shipper:
    image: fluent/fluent-bit:latest
    volumes:
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
    networks: [public]
    deploy:
      resources:
        limits:
          memory: 64M

networks:
  public:
  data:
    driver: overlay
    internal: true

volumes:
  pgdata:

secrets:
  db_password:
    external: true
  database_url:
    external: true

configs:
  nginx_conf:
    file: ./nginx/default.conf

Deploying it

docker swarm init   # if not already a Swarm

echo "supersecretpassword" | docker secret create db_password -
echo "postgres://app:supersecretpassword@db:5432/links" | docker secret create database_url -

docker stack deploy -c stack.yml linkshortener
docker stack services linkshortener
docker service ps linkshortener_api

Verifying the hardening actually holds

# api runs as non-root
docker exec $(docker ps -q -f name=linkshortener_api) whoami
# node, not root

# secret is file-mounted, not an env var
docker exec $(docker ps -q -f name=linkshortener_api) env | grep -i database_url
# nothing — the real DATABASE_URL_FILE points at a mounted file, not an inline value

# data network cannot reach outside
docker exec $(docker ps -q -f name=linkshortener_db) sh -c "wget -T 3 -qO- https://example.com" ; echo $?
# fails — data network is internal: true

# resource limits are enforced
docker inspect $(docker ps -q -f name=linkshortener_api) -f '{{.HostConfig.Memory}}'
# 268435456 (256MB in bytes)

How It Actually Works

Why the rolling update (update_config) matters specifically for a service holding a database connection secret. When api's image is updated, Swarm's rolling update (module 08's reconciliation loop, applied one task at a time per parallelism: 1) starts a new task alongside the old ones before stopping any, waits for it to report healthy via the HEALTHCHECK before proceeding to the next, and never removes more capacity than it has already replaced. Combined with db's secrets being mounted identically for every replica by name (not baked into any specific image build), a secret rotation and an image update can proceed independently: rotating database_url (module 09's --secret-add pattern) doesn't require rebuilding the api image, and updating the api image doesn't require touching the secret.

Why the layered network topology (public/data internal: true) plus per-service memory limits together bound the blast radius of a single compromised container more effectively than either alone. A resource limit constrains what a compromised process can consume (module 03's cgroup enforcement) but says nothing about what it can reach; network isolation constrains what it can reach (module 01's routing/NAT enforcement) but says nothing about resource exhaustion. A compromised api replica, memory-capped and only able to reach public and data, cannot exhaust the host's total memory (kernel OOM killer scoped to its own cgroup, per module 03) and cannot pivot to attack anything outside those two networks (no route exists, per module 01) — each control independently closes a different failure mode, which is why production hardening treats them as complementary layers rather than picking one.

Exercise

Add a --limit-cpu/--limit-memory breach test: temporarily lower api's memory limit to something unreasonably small (e.g. 32M), redeploy the stack, and observe via docker service ps and docker service logs that replicas now OOM-kill and restart in a loop (module 03's 137 exit code, module 08's automatic rescheduling) — then restore the original limit and confirm the service stabilizes.