Skip to main content

Custom Nginx, Apache, and Tomcat Images

Official images on Docker Hub contain only minimal configuration for general-purpose use. In real services, you need to create custom images that include your own configuration files, application code, and security hardening options. Codifying images with Dockerfiles guarantees environment reproducibility and integrates naturally with CI/CD pipelines.


Core Dockerfile Principles​

Understand the key principles before writing custom images.

PrincipleDescription
Pin base image versionSpecify concrete versions like nginx:1.25-alpine instead of nginx:alpine
Prefer alpine-based60-80% size reduction vs standard images, minimizes attack surface
Minimize layersChain RUN commands with && to reduce layer count
Non-root userUse USER directive to run as non-root
.dockerignoreExclude unnecessary files from build context

.dockerignore Configuration​

Limit files included in the build context to improve build speed and security.

# .dockerignore

# Version control
.git/
.gitignore

# Build artifacts (Java/Maven)
target/
*.class

# Build artifacts (Node.js)
node_modules/
dist/
build/

# Environment variables and secrets (never include in image)
.env
.env.*
*.key
*.pem

# Log files
*.log
logs/

# IDE settings
.idea/
.vscode/
*.iml

# OS files
.DS_Store
Thumbs.db

# Documentation
*.md
docs/

Custom Nginx Image​

Dockerfile​

# nginx/Dockerfile

# Base: nginx:alpine (lightweight, ~7MB)
FROM nginx:1.25-alpine

# Metadata
LABEL maintainer="devteam@example.com"
LABEL version="1.0"
LABEL description="Custom Nginx reverse proxy image"

# Remove default config and copy custom settings
RUN rm /etc/nginx/conf.d/default.conf

# Copy custom nginx.conf
COPY nginx.conf /etc/nginx/nginx.conf

# Copy virtual host configurations
COPY conf.d/ /etc/nginx/conf.d/

# Copy static files (including built frontend)
COPY --chown=nginx:nginx static/ /usr/share/nginx/html/static/

# Create SSL certificate directory (volume mount location)
RUN mkdir -p /etc/nginx/ssl

# Set log directory permissions
RUN chown -R nginx:nginx /var/log/nginx \
&& chmod 755 /var/log/nginx

# Validate nginx configuration
RUN nginx -t

# Run as non-root user (can only use ports 1024+ for processes)
# nginx runs as master(root) + worker(nginx) structure
USER nginx

EXPOSE 80 443

# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost/health || exit 1

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

nginx.conf Example (COPY target)​

# nginx.conf
user nginx;
worker_processes auto; # Auto-set based on CPU core count
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
worker_connections 1024;
use epoll; # Linux high-performance I/O
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

# Log format (switching to JSON format makes log collection easier)
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;

sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript;

include /etc/nginx/conf.d/*.conf;
}

Custom Tomcat Image​

Dockerfile (WAR Deployment Method)​

# tomcat/Dockerfile

FROM tomcat:10.1-jdk17-temurin-alpine

LABEL maintainer="devteam@example.com"

# Remove default webapps (ROOT, examples, docs, etc.)
RUN rm -rf /usr/local/tomcat/webapps/*

# Copy custom server.xml (connector settings, port changes, etc.)
COPY server.xml /usr/local/tomcat/conf/server.xml

# Copy context.xml (DB connection pool JNDI settings, etc.)
COPY context.xml /usr/local/tomcat/conf/context.xml

# Copy WAR file (built artifact)
COPY target/myapp.war /usr/local/tomcat/webapps/ROOT.war

# JVM memory settings and GC tuning
ENV JAVA_OPTS="-server \
-Xms512m \
-Xmx1024m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+UseContainerSupport \
-Djava.security.egd=file:/dev/./urandom \
-Dfile.encoding=UTF-8 \
-Duser.timezone=Asia/Seoul"

# catalina.sh environment variable settings
ENV CATALINA_OPTS="-Dspring.profiles.active=prod"

# Log directory
VOLUME /usr/local/tomcat/logs

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
CMD curl -f http://localhost:8080/ || exit 1

CMD ["catalina.sh", "run"]

Spring Boot Multi-Stage Build​

Multi-stage builds separate the build environment (JDK + Maven/Gradle) from the runtime environment (JRE) to dramatically reduce the final image size.

Maven-Based Multi-Stage Build​

# Dockerfile

# ══════════════════════════════════════════════════════
# Stage 1: Build (Maven + JDK)
# ══════════════════════════════════════════════════════
FROM maven:3.9-eclipse-temurin-17-alpine AS builder

WORKDIR /build

# Dependency caching optimization: copy pom.xml first to avoid
# re-downloading when only source changes
COPY pom.xml .
COPY .mvn/ .mvn/
RUN mvn dependency:go-offline -B # Pre-download dependencies

# Copy source code and build
COPY src/ src/
RUN mvn package -DskipTests -B \
&& mv target/*.jar target/app.jar

# ══════════════════════════════════════════════════════
# Stage 2: JRE Layer Separation (using jlink)
# ══════════════════════════════════════════════════════
FROM eclipse-temurin:17-jdk-alpine AS runtime-builder

COPY --from=builder /build/target/app.jar app.jar

# Extract Spring Boot layered JAR (layer caching optimization)
RUN java -Djarmode=layertools -jar app.jar extract

# ══════════════════════════════════════════════════════
# Stage 3: Runtime Image (JRE only)
# ══════════════════════════════════════════════════════
FROM eclipse-temurin:17-jre-alpine

# Security: create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy Spring Boot layered JAR (less-changed layers first)
COPY --from=runtime-builder --chown=appuser:appgroup dependencies/ ./
COPY --from=runtime-builder --chown=appuser:appgroup spring-boot-loader/ ./
COPY --from=runtime-builder --chown=appuser:appgroup snapshot-dependencies/ ./
COPY --from=runtime-builder --chown=appuser:appgroup application/ ./

# Create log directory
RUN mkdir -p /app/logs && chown -R appuser:appgroup /app/logs

# Run as non-root user
USER appuser

# JVM options
ENV JAVA_OPTS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:+UseG1GC \
-Dfile.encoding=UTF-8 \
-Duser.timezone=Asia/Seoul"

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=90s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

Gradle-Based Multi-Stage Build​

# Dockerfile.gradle

# Stage 1: Build
FROM gradle:8.5-jdk17-alpine AS builder

WORKDIR /build

# Dependency caching (Gradle wrapper + build files first)
COPY gradlew .
COPY gradle/ gradle/
COPY build.gradle settings.gradle ./
RUN ./gradlew dependencies --no-daemon # Pre-resolve dependencies

# Copy source and build
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test \
&& mv build/libs/*.jar build/libs/app.jar

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine

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

WORKDIR /app

COPY --from=builder --chown=appuser:appgroup /build/build/libs/app.jar app.jar

USER appuser

ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"

EXPOSE 8080

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Image Size Comparison​

MethodImage Size
JDK + full JAR~600MB
JRE + full JAR~250MB
JRE + layered JAR~250MB (better caching efficiency)
alpine JRE + layered JAR~180MB

Custom Apache Image​

# apache/Dockerfile

FROM httpd:2.4-alpine

LABEL maintainer="devteam@example.com"

# Copy custom httpd.conf
COPY httpd.conf /usr/local/apache2/conf/httpd.conf

# Copy additional config files (mod_proxy, SSL, etc.)
COPY conf.d/ /usr/local/apache2/conf/extra/

# Copy static files
COPY --chown=daemon:daemon htdocs/ /usr/local/apache2/htdocs/

# Set log directory permissions
RUN chown -R daemon:daemon /usr/local/apache2/logs

# Validate configuration
RUN httpd -t

EXPOSE 80 443

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost/ || exit 1

CMD ["httpd-foreground"]

httpd.conf Key Module Activation​

# httpd.conf - Tomcat integration via mod_proxy

# Activate required modules
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
LoadModule proxy_ajp_module modules/mod_proxy_ajp.so
LoadModule headers_module modules/mod_headers.so
LoadModule rewrite_module modules/mod_rewrite.so

ServerName example.com

# Proxy configuration
<VirtualHost *:80>
ServerName example.com

# Reverse proxy to Tomcat
ProxyPreserveHost On
ProxyPass / http://app:8080/
ProxyPassReverse / http://app:8080/

# Forward proxy headers
RequestHeader set X-Forwarded-Proto "http"
RequestHeader set X-Forwarded-Port "80"

ErrorLog /usr/local/apache2/logs/error.log
CustomLog /usr/local/apache2/logs/access.log combined
</VirtualHost>

Building and Tagging Images​

Basic Build​

# Build image with Dockerfile in current directory
docker build -t myapp:1.0 .

# Specify a specific Dockerfile
docker build -f Dockerfile.prod -t myapp:1.0-prod .

# Pass build arguments
docker build --build-arg BUILD_VERSION=1.0.0 -t myapp:1.0 .

# Build only up to a specific stage in multi-stage (for debugging)
docker build --target builder -t myapp:builder .

# Force rebuild without cache
docker build --no-cache -t myapp:1.0 .

Image Tagging Strategy​

# Semantic Versioning tagging
docker build -t myapp:1.2.3 .
docker tag myapp:1.2.3 myapp:1.2
docker tag myapp:1.2.3 myapp:1
docker tag myapp:1.2.3 myapp:latest

# Git commit hash tagging (recommended for CI/CD)
GIT_HASH=$(git rev-parse --short HEAD)
docker build -t myapp:${GIT_HASH} .
docker tag myapp:${GIT_HASH} myapp:latest

Pushing to Docker Hub / Private Registry​

Docker Hub​

# Docker Hub login
docker login

# Tag image (Hub format: account/image:tag)
docker tag myapp:1.0 username/myapp:1.0
docker tag myapp:1.0 username/myapp:latest

# Push image
docker push username/myapp:1.0
docker push username/myapp:latest

# Pull image
docker pull username/myapp:1.0

AWS ECR (Elastic Container Registry)​

# Login to ECR via AWS CLI
aws ecr get-login-password --region ap-northeast-2 | \
docker login --username AWS --password-stdin \
123456789012.dkr.ecr.ap-northeast-2.amazonaws.com

# Tag in ECR format
ECR_URI=123456789012.dkr.ecr.ap-northeast-2.amazonaws.com
docker tag myapp:1.0 ${ECR_URI}/myapp:1.0

# Push
docker push ${ECR_URI}/myapp:1.0

Self-Hosted Private Registry (Harbor / Registry)​

# Run Docker Registry container (for local testing)
docker run -d \
-p 5000:5000 \
--name registry \
-v registry-data:/var/lib/registry \
registry:2

# Tag and push to local registry
docker tag myapp:1.0 localhost:5000/myapp:1.0
docker push localhost:5000/myapp:1.0

# Pull from private registry
docker pull localhost:5000/myapp:1.0

Using Private Registry Images in docker-compose.yml​

services:
app:
image: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:1.0
# Use registry image directly without a build section

Image Optimization Practical Summary​

# Check image size
docker images myapp

# Analyze size by layer
docker history myapp:1.0

# Inspect image internal filesystem (dive tool)
# docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \
# wagoodman/dive:latest myapp:1.0

# Clean up unnecessary images
docker image prune -f

# Clean up build cache
docker builder prune -f

Expert Tips​

1. COPY vs ADD

ADD can download files from URLs or automatically extract tar files, but to prevent unpredictable behavior, always use COPY for simple file copying.

2. Set ownership in one step with --chown flag

# ❌ Creates two layers
COPY app.jar /app/
RUN chown appuser:appgroup /app/app.jar

# βœ… Solved with one layer
COPY --chown=appuser:appgroup app.jar /app/

3. XX:+UseContainerSupport JVM option

From JDK 8u191 and JDK 10 onward, the JVM automatically recognizes container memory limits. Using -XX:MaxRAMPercentage=75.0 instead of hardcoding -Xmx automatically adapts when container memory changes.

4. Be careful with secret build arguments

# ❌ Secrets passed via ARG are exposed in docker history
ARG DB_PASSWORD
ENV DB_PASS=${DB_PASSWORD}

# βœ… Inject as environment variable at runtime (use environment in docker-compose.yml)

Embedding sensitive information in images at build time exposes them via docker history. Always inject all sensitive information at runtime via environment variables or Docker Secrets.