DevOpsil
Linux
88%
Needs Review

Alpine Linux for Docker: Building Minimal, Secure Container Images

Raafay AsifRaafay Asif6 min read

Why Alpine Linux Dominates Container Base Images

Alpine Linux is a ~5MB Linux distribution built around musl libc and BusyBox. For containers, this matters: smaller base images mean faster pulls, less surface area for vulnerabilities, and lower attack surface in production.

Compare a typical base image size:

  • ubuntu:24.04 — ~77MB
  • debian:bookworm-slim — ~75MB
  • alpine:3.20 — ~7MB

The size difference becomes significant when you're running hundreds of containers or scanning every layer for CVEs.


Understanding Alpine's musl libc

The main compatibility consideration with Alpine is that it uses musl libc instead of glibc (GNU C Library). Most software written for Linux assumes glibc. Issues arise when:

  1. A binary is dynamically linked to glibc — it will crash on Alpine
  2. A language runtime (Python, Node.js, Ruby) has a C extension compiled against glibc
  3. Software uses glibc-specific features (getaddrinfo with specific flags, dlopen behavior, etc.)
# Check what libraries a binary needs
ldd /usr/bin/myapp

# If it shows /lib/x86_64-linux-gnu/libc.so.6, it needs glibc
# Alpine provides /lib/ld-musl-x86_64.so.1

# Test a binary on Alpine
docker run --rm -v $(pwd)/myapp:/myapp alpine:3.20 /myapp
# If it fails: "not found" or "exec format error" — glibc dependency

For most interpreted languages (Python, Node.js, Ruby) and compiled-from-source Go binaries, Alpine works without issues. For pre-compiled glibc binaries, use a glibc-based image instead.


Basic Dockerfile with Alpine

FROM alpine:3.20

# apk is Alpine's package manager
RUN apk add --no-cache nginx

# Alpine uses /etc/nginx, same structure as other distros
COPY nginx.conf /etc/nginx/nginx.conf
COPY html/ /var/www/html/

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

Key flags:

  • --no-cache — don't cache the index in the image layer (reduces size by ~5MB)
  • Without --no-cache, Alpine creates /var/cache/apk/ in the layer
docker build -t myapp:latest .
docker images myapp:latest
# REPOSITORY   TAG       IMAGE ID       SIZE
# myapp        latest    abc123         12.5MB   ← nginx on Alpine

Multi-Stage Builds: The Right Way to Use Alpine

The real power of Alpine is in multi-stage builds — use a full build environment to compile your application, then copy only the artifact to a minimal Alpine runtime image.

Go Application

Go binaries can be compiled statically (no external dependencies), making them ideal for Alpine or even scratch:

# Stage 1: Build
FROM golang:1.22-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /myapp ./cmd/server

# Stage 2: Runtime
FROM alpine:3.20

# ca-certificates is needed for HTTPS calls
RUN apk add --no-cache ca-certificates tzdata

# Run as non-root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app
COPY --from=builder /myapp .

USER appuser

EXPOSE 8080
CMD ["/app/myapp"]

Final image size: ~15MB (7MB Alpine + ~8MB Go binary). Compare to golang:1.22 base: ~800MB.

Node.js Application

Node.js on Alpine requires node:alpine tag (or node:lts-alpine):

# Stage 1: Dependencies
FROM node:22-alpine AS deps

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Build (if needed, e.g., TypeScript)
FROM node:22-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Runtime
FROM node:22-alpine AS runtime

ENV NODE_ENV=production
WORKDIR /app

RUN addgroup -S nodejs && adduser -S nextjs -G nodejs

COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json .

USER nextjs
EXPOSE 3000
CMD ["node", "dist/index.js"]

Python Application

FROM python:3.12-alpine AS builder

WORKDIR /app
COPY requirements.txt .

# Install build deps for packages with C extensions (e.g., cryptography, psycopg2)
RUN apk add --no-cache --virtual .build-deps \
    gcc musl-dev libffi-dev openssl-dev postgresql-dev && \
    pip install --prefix=/install -r requirements.txt && \
    apk del .build-deps

FROM python:3.12-alpine

WORKDIR /app
COPY --from=builder /install /usr/local

# Runtime library deps (not build deps)
RUN apk add --no-cache libpq

COPY . .

RUN adduser -D appuser
USER appuser

CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

The --virtual .build-deps pattern installs build tools, compiles packages, then removes the build tools — keeping the image layer clean.


Common apk Operations in Dockerfiles

# Install multiple packages (one RUN to minimize layers)
RUN apk add --no-cache curl bash git openssh-client

# Install a specific version
RUN apk add --no-cache nginx=1.26.2-r0

# Add a testing or edge repository
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main \
    some-package

# List available package versions
apk list nginx

# Search for a package
apk search nginx

# Show package info and dependencies
apk info -d nginx

Security Best Practices for Alpine Containers

1. Run as Non-Root

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

2. Read-Only Filesystem

docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /var/run \
  myapp:latest

Or in docker-compose:

services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /var/run

3. No New Privileges

docker run --security-opt=no-new-privileges myapp:latest

4. Pin Image Versions

# Bad: pulls whatever is current
FROM alpine:latest

# Good: pin to a specific version
FROM alpine:3.20.3

# Better: pin to a digest (immutable)
FROM alpine@sha256:a8560b36e8b8210634f77d9a1296573b08b9a20a0a0e...

5. Scan for Vulnerabilities

# Trivy (recommended)
docker run --rm aquasec/trivy image myapp:latest

# Grype
docker run --rm anchore/grype myapp:latest

# Check Alpine CVE status
# https://security.alpinelinux.org/

When NOT to Use Alpine

Alpine is not always the right choice:

  1. glibc-dependent binaries: Use debian:slim or ubuntu instead
  2. Java applications: JVM expects glibc; use eclipse-temurin:21-jre (glibc-based)
  3. Debugging in production: Alpine's minimal tooling makes debugging harder — consider distroless or a debug variant with tools added
  4. Complex DNS-dependent apps: musl's getaddrinfo doesn't support ndots the same way glibc does, causing DNS resolution differences in Kubernetes

For Java specifically:

# Use official JRE image (glibc-based) not Alpine
FROM eclipse-temurin:21-jre-jammy

Minimal Dockerfile Checklist

PracticeWhy
apk add --no-cacheRemoves cache from layer
Multi-stage buildKeeps build tools out of final image
Single RUN for apk installsFewer layers
Non-root userReduces blast radius if compromised
--no-new-privilegesPrevents privilege escalation
Pin image versionsReproducible builds
Remove dev packages after buildOnly runtime deps in final image

Alpine's combination of small size, active maintenance, and straightforward apk tooling makes it the default choice for container base images when glibc compatibility isn't a concern.

Share:

Was this article helpful?

Raafay Asif
Raafay Asif

Linux Systems Engineer

Everything runs on Linux — I make sure it runs well. From kernel tuning to systemd debugging, I live in the terminal. If your server is misbehaving, I've probably seen that exact dmesg output before.

Related Articles

More in Linux

View all →

Discussion