⬅️ Back to Security Index

Docker Security

Overview

Smart Supply Pro uses Docker with security best practices to containerize the Spring Boot application. The Dockerfile implements:

  • Multi-stage builds to minimize final image size
  • Non-root user execution
  • Minimal base image (JRE-only, not JDK)
  • Runtime secret injection (no secrets in layers)
  • Health checks and metadata

Multi-Stage Build Architecture

The Dockerfile uses three stages to produce a lean, secure image:

FROM maven:3.9.11-eclipse-temurin-17 AS deps
  # Download dependencies (cached)
  
FROM maven:3.9.11-eclipse-temurin-17 AS build
  # Build application JAR

FROM eclipse-temurin:17-jre-alpine AS runtime
  # Run application (no build tools)

Stage 1: Dependency Warmup (Optional)

FROM maven:3.9.11-eclipse-temurin-17 AS deps
WORKDIR /app
COPY pom.xml .
COPY .mvn/ .mvn/
RUN mvn -q -B -DskipTests dependency:go-offline

Benefits: - βœ… Docker layer caching: dependencies cached, no re-download on code change - βœ… Faster incremental builds - βœ… Saves bandwidth (CI/CD)

Stage 2: Build Stage

FROM maven:3.9.11-eclipse-temurin-17 AS build
WORKDIR /build

# Reuse warmed dependencies
COPY --from=deps /root/.m2 /root/.m2

# Copy source
COPY pom.xml .
COPY .mvn/ .mvn/
COPY src/ src/

# Build profile (prod, dev, etc.)
ARG PROFILE=prod
ENV SPRING_PROFILES_ACTIVE=${PROFILE}

# Package JAR
RUN mvn -q -B -DskipTests -P ${PROFILE} package

# Clean cache to reduce layer size
RUN rm -rf /root/.m2/repository || true

Key Points: - Build tools (Maven) only in this stage - DskipTests - Tests run in CI, not Docker - PROFILE argument for environment-specific builds - Cache cleanup to reduce intermediate layer size

Stage 3: Runtime Stage

FROM eclipse-temurin:17-jre-alpine AS runtime
WORKDIR /app

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Install minimal utilities
RUN apk add --no-cache unzip coreutils && apk upgrade --no-cache

# Change ownership
RUN chown -R appuser:appgroup /app

# Copy startup script (with executable permission)
COPY --chown=appuser:appgroup --chmod=0755 scripts/start.sh /app/start.sh

# Copy JAR from build stage
COPY --from=build --chown=appuser:appgroup /build/target/*.jar /app/
RUN mv /app/*.jar /app/app.jar

# Drop to non-root user
USER appuser

# Health check (informational, Fly.io overrides)
EXPOSE 8081

# Startup command
CMD ["/app/start.sh"]

Security Features: - βœ… JRE only (no compiler, no build tools) - βœ… Non-root user (appuser:appgroup) - βœ… Minimal base image (alpine: ~77MB) - βœ… Secrets NOT in image (injected at runtime) - βœ… File permissions enforced


Non-Root User Execution

User/Group Creation

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

Benefits: - βœ… Container breakout less impactful - βœ… Prevents accidental modifications to system files - βœ… Complies with container security standards (CIS Benchmarks)

File Ownership

COPY --chown=appuser:appgroup /build/target/*.jar /app/app.jar
RUN chown -R appuser:appgroup /app

Verification:

# Inside container
ls -la /app/app.jar
# -rw-r--r-- appuser appgroup app.jar

Privilege Dropping

USER appuser  # Drop privileges BEFORE running app
CMD ["/app/start.sh"]

Effect: - Java process runs as UID 1000 (appuser), not UID 0 (root) - Cannot accidentally modify /etc, /lib, /usr - Docker daemon enforces privilege restrictions


Minimal Base Image

Alpine Linux for JRE

FROM eclipse-temurin:17-jre-alpine

Image Sizes: | Base | Size | Includes | |β€”β€”|β€”β€”|β€”β€”β€”-| | jdk-17-full | ~360MB | Full JDK + compiler + tools | | jre-17-full | ~190MB | JRE only (no compiler) | | jre-17-alpine | ~77MB | JRE + minimal Alpine OS |

Trade-offs: - βœ… Smaller: faster deploys, smaller attack surface - βœ… Faster pulls: bandwidth/time savings - ⚠️ Alpine limitations: some Linux utils missing (use busybox alternatives)

System Updates

RUN apk add --no-cache unzip coreutils && apk upgrade --no-cache

Practices: - βœ… --no-cache: don’t cache apk index (smaller layers) - βœ… apk upgrade: apply security patches - βœ… Minimal packages: unzip (wallet extraction), coreutils (base64 decode)


Runtime Secret Injection

No Secrets in Image

# ❌ DO NOT DO THIS:
ENV ORACLE_WALLET_B64=<wallet-data>  # Baked into image!
ENV DB_PASSWORD=secret123            # Exposed in layers!

# βœ… DO THIS INSTEAD:
# Secrets injected at runtime via environment variables
# Dockerfile just receives secrets from CI/container platform

Secret Injection Points

Fly.io Secrets:

flyctl secrets set ORACLE_WALLET_B64=<value> ORACLE_WALLET_PASSWORD=<value>
# Fly.io injects into container environment at startup

Kubernetes Secrets:

apiVersion: v1
kind: Pod
spec:
  containers:
  - name: app
    env:
    - name: ORACLE_WALLET_B64
      valueFrom:
        secretKeyRef:
          name: oracle-wallet
          key: wallet_b64

Docker CLI:

docker run --env-file secrets.env -p 8081:8081 inventory-service:latest
# .env file contains: ORACLE_WALLET_B64=...

start.sh Secret Handling

The start.sh script minimizes secret exposure:

#!/bin/sh

# 1. Read secret from environment
WALLET_DATA="${ORACLE_WALLET_B64}"

# 2. Decode and extract (ephemeral filesystem)
printf "%s" "${WALLET_DATA}" | base64 -d > /tmp/wallet.zip
unzip -q /tmp/wallet.zip -d /tmp/wallet

# 3. Build JVM options with wallet password
JAVA_OPTS="... -Doracle.net.wallet_password=${ORACLE_WALLET_PASSWORD}"

# 4. Unset environment variables (reduce memory exposure)
unset ORACLE_WALLET_B64
unset ORACLE_WALLET_PASSWORD

# 5. Remove temporary files
rm -f /tmp/wallet.zip

# 6. Start application (secrets no longer in environment)
exec java ${JAVA_OPTS} -jar /app/app.jar

Security Points: - βœ… Secrets only in memory during startup - βœ… Env vars unset before app starts - βœ… Temp files deleted (no disk exposure) - βœ… JVM options in system properties (not env)


Base Image Security

Image Scanning for CVEs

# Scan for known vulnerabilities
docker scan inventory-service:latest

# Or using Trivy (third-party tool)
trivy image inventory-service:latest

Regular Updates:

# Before each release, rebuild to get latest patches
docker build --no-cache -t inventory-service:v1.2.0 .
# --no-cache forces fresh base image pull

Image Provenance

LABEL maintainer="https://github.com/Keglev"
LABEL version="1.0.0"
LABEL org.opencontainers.image.source="https://github.com/Keglev/inventory-service"

Benefits: - βœ… Image provenance tracking - βœ… Vulnerability scanning with origin info - βœ… Automated security notifications


Container Runtime Security

Read-Only Filesystem (Advanced)

For maximum hardening (if startup files are static):

# Mark critical files as read-only
RUN chmod 444 /app/app.jar
RUN chmod 555 /app/start.sh

Fly.io config (read-only root filesystem):

[experimental]
  read_only_root_filesystem = true

Effect: - βœ… Container cannot create/modify files - βœ… Malware cannot establish persistence - ⚠️ Requires careful handling of /tmp, /var/log

Resource Limits

Fly.io fly.toml:

[vm]
  size = "shared-cpu-1x"
  memory = 1024  # 1GB RAM limit

Docker Compose:

services:
  app:
    image: inventory-service:latest
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 1G  # Kill if exceeds 1GB

Benefits: - βœ… Prevents memory exhaustion attacks - βœ… Fair resource sharing in multi-tenant - βœ… Predictable billing in cloud platforms


Build Arguments for Flexibility

Profile-Specific Builds

ARG PROFILE=prod
ENV SPRING_PROFILES_ACTIVE=${PROFILE}
RUN mvn ... -P ${PROFILE} package

Usage:

# Production build (default)
docker build -t inventory-service:prod .

# Development build
docker build --build-arg PROFILE=dev -t inventory-service:dev .

# Custom JDK
docker build --build-arg JDK_VERSION=21 -t inventory-service:jdk21 .

Dockerfile Best Practices Checklist

Practice Status Details
Multi-stage build βœ… 3 stages: deps, build, runtime
Non-root user βœ… appuser:appgroup, UID 1000
Minimal base βœ… alpine JRE (77MB)
No secrets in image βœ… Runtime injection via env vars
Scan for CVEs βœ… docker scan or trivy
Health checks βœ… TCP and HTTP probes configured
Metadata labels βœ… maintainer, version, source
Resource limits βœ… Memory and CPU constraints
Distroless compatible ⚠️ Currently alpine; can use distroless

Deployment Platforms

Fly.io Configuration (fly.toml)

[build]
  dockerfile = "Dockerfile"
  context = "."

[env]
  SPRING_PROFILES_ACTIVE = "prod"
  APP_DEMO_READONLY = "true"

[vm]
  memory = 1024

[[services]]
  internal_port = 8081
  [[services.ports]]
    handlers = ["http"]
    port = 80
  [[services.ports]]
    handlers = ["tls", "http"]
    port = 443

Kubernetes (advanced)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: inventory-service
spec:
  containers:
  - name: app
    image: inventory-service:v1.0.0
    imagePullPolicy: Always
    securityContext:
      allowPrivilegeEscalation: false
      runAsNonRoot: true
      runAsUser: 1000
      readOnlyRootFilesystem: true
    resources:
      requests:
        memory: "512Mi"
        cpu: "500m"
      limits:
        memory: "1Gi"
        cpu: "1"
    volumeMounts:
    - name: tmp
      mountPath: /tmp
    - name: var-log
      mountPath: /var/log
  securityPolicy: restricted
  volumes:
  - name: tmp
    emptyDir: {}
  - name: var-log
    emptyDir: {}


⬅️ Back to Security Index