Szymon Borowski
Extended\Mind::Thesis()
The mind extends beyond the skull — into tools, notes, and environment. — Clark & Chalmers, 1998

Multi-stage Docker builds — Alpine, hardening and minimal production images

Szymon Borowski ·

The problem with simple Dockerfiles

A naive Dockerfile for a Laravel application:

FROM php:8.5-fpm
RUN apt-get install -y git zip unzip nodejs npm
COPY . /var/www
RUN composer install
RUN npm install && npm run build

Resulting image: ~1.2 GB. It contains build-time tools (git, npm, composer) that production does not need.

Multi-stage build — splitting into stages

# Stage 1: build assets (Node.js)
FROM node:22-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build

# Stage 2: install PHP dependencies
FROM composer:2 AS composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction

# Stage 3: production image
FROM php:8.5-fpm-alpine AS production
WORKDIR /var/www

# Copy only the artifacts from previous stages
COPY --from=composer /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .

RUN chown -R www-data:www-data /var/www
USER www-data

Resulting image: ~180 MB — 85% smaller.

Container hardening

A few security principles I follow:

Non-root user:

RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -s /bin/sh -D appuser
USER appuser

Read-only filesystem (where possible):

# docker-compose.prod.yml
security_opt:
  - no-new-privileges:true
read_only: true
tmpfs:
  - /tmp
  - /var/run

Minimal image — Alpine instead of Debian: Alpine Linux is ~5 MB compared to ~80 MB for Debian. Fewer packages = smaller attack surface.

STOPSIGNAL SIGQUIT for PHP-FPM:

STOPSIGNAL SIGQUIT

PHP-FPM performs a graceful shutdown on SIGQUIT — it finishes active requests before shutting down. Without this, K8s could kill the container in the middle of handling a request.

OCI labels for traceability

LABEL org.opencontainers.image.version="0.0.4"
LABEL org.opencontainers.image.revision="${GIT_COMMIT}"
LABEL org.opencontainers.image.created="${BUILD_DATE}"
LABEL org.opencontainers.image.source="https://github.com/szymonborowski/portfolio"

This way, when looking at a running container I know exactly which commit it came from.

Likes
Login — Log in to leave a comment.

Comments

No comments yet