Tutorial

How to Create a Custom Docker Image: Dockerfile Best Practices

May 30, 2026

Back to Blog
A modern alternative to cPanel, Plesk and CyberPanel — isolated, secure, AI-assisted.
Start free

Why Custom Docker Images Matter

Docker Hub hosts millions of pre-built images, but production applications need more than a generic base image. You need your specific runtime version, your dependencies pre-installed, your configuration baked in, and your security policies enforced. Custom Docker images give you complete control over what runs inside your containers, from the operating system layer all the way up to your application code.

Building good Docker images is both an art and a science. A poorly built image can be gigabytes in size, take minutes to build, contain security vulnerabilities, and leak sensitive data. A well-built image is compact, builds in seconds with caching, runs as a non-root user, and includes nothing beyond what your application needs. This guide covers everything you need to know to build production-grade Docker images.

Dockerfile Instruction Reference

Before diving into best practices, let us review every Dockerfile instruction you will use. Understanding what each instruction does and how it affects the build cache and final image is essential.

InstructionPurposeCache Impact
FROMSets the base imageCreates a new build stage
RUNExecutes commands during buildCreates a new layer
COPYCopies files from host to imageInvalidates cache on file change
ADDLike COPY but with URL/tar supportPrefer COPY unless you need extras
CMDDefault command when container startsOnly one per Dockerfile
ENTRYPOINTFixed executable for the containerCMD becomes arguments to ENTRYPOINT
ENVSets environment variablesPersists in running container
ARGBuild-time variablesNot available in running container
WORKDIRSets working directoryCreates dir if it does not exist
USERSets the user for subsequent instructionsSecurity best practice
EXPOSEDocuments which ports the container listens onMetadata only, does not publish
VOLUMECreates a mount point for persistent dataData persists across container lifecycle
HEALTHCHECKDefines how to test if the container is healthyUsed by orchestrators for routing
LABELAdds metadata to the imageOCI annotations, maintainer info

Your First Dockerfile

Let us start with a simple Dockerfile for a Node.js application and then progressively improve it:

# Dockerfile (basic version)
FROM node:20

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

EXPOSE 3000
CMD ["node", "server.js"]

This works, but it has several problems: the image is over 1GB (because node:20 includes the full Debian OS), it runs as root, it includes development dependencies, and changing any source file invalidates the npm install cache. Let us fix each of these issues.

Choosing the Right Base Image

The base image you choose has a massive impact on your final image size, security posture, and build time. Here is how the common base image variants compare:

Base ImageSizeIncludesBest For
node:20~1.1 GBFull Debian + build toolsDevelopment, building native modules
node:20-slim~240 MBMinimal DebianApps without native dependencies
node:20-alpine~140 MBAlpine Linux + musl libcSmallest possible image
gcr.io/distroless/nodejs20~130 MBNode.js runtime only, no shellMaximum security
Alpine Compatibility Warning: Alpine uses musl libc instead of glibc. Some Node.js native modules (like sharp, bcrypt, or anything using node-gyp) may not compile or may behave differently. Always test your application with Alpine before committing to it in production.

Multi-Stage Builds: The Most Important Optimization

Multi-stage builds are the single most impactful technique for reducing Docker image size. The idea is simple: use one stage with full build tools to compile your application, then copy only the output to a minimal runtime stage.

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app

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

# Copy production deps aside
RUN cp -R node_modules /prod_modules

# Install all deps (including devDependencies) for build
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app

# Non-root user
RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -s /bin/sh -D appuser

# Copy only what we need
COPY --from=builder /prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

USER appuser
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "dist/server.js"]
1.1 GB
Single-stage image size
180 MB
Multi-stage image size

Layer Caching: Order Matters

Docker builds images layer by layer. When a layer changes, all subsequent layers are rebuilt. This means the order of your Dockerfile instructions directly impacts build speed.

The Golden Rule of Layer Caching: Put instructions that change least frequently at the top and instructions that change most frequently at the bottom. System packages change rarely, dependencies change sometimes, and source code changes constantly.
# BAD: Copying all files before installing dependencies
COPY . . Any file change invalidates npm install cache
RUN npm install

# GOOD: Copy only package files first
COPY package*.json ./ Only invalidated when deps change
RUN npm install
COPY . . Source code changes do not rebuild deps

The .dockerignore File

Just like .gitignore, the .dockerignore file prevents unnecessary files from being sent to the Docker daemon during builds. Without it, Docker sends your entire project directory (including node_modules, .git, and build artifacts) as the build context.

# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
Dockerfile
.dockerignore
docker-compose*.yml
.env*
*.md
tests/
coverage/
dist/
.vscode/
.idea/

Reducing Image Size

Every megabyte matters when you are pulling images across networks, storing them in registries, and spinning up containers. Here are the most effective techniques for reducing image size:

Combine RUN Instructions

Each RUN instruction creates a new layer. Even if you delete files in a later RUN, the deleted files still exist in the previous layer. Combine related commands into a single RUN instruction.

# BAD: Three layers, cache files persist in layer 1
RUN apt-get update
RUN apt-get install -y curl wget
RUN apt-get clean

# GOOD: Single layer, cache cleaned in same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl wget && \
    apt-get clean && apt-get autoremove -y

Use --no-install-recommends

Debian and Ubuntu apt installs recommended packages by default, which can add hundreds of megabytes of unnecessary software. Always use --no-install-recommends and clean the apt cache in the same layer.

ENTRYPOINT vs CMD

Understanding the difference between ENTRYPOINT and CMD is crucial for building flexible, composable images.

FeatureENTRYPOINTCMD
PurposeDefines the executableProvides default arguments
Override at runtimeRequires --entrypointSimply append to docker run
When both are setENTRYPOINT runsCMD becomes arguments to ENTRYPOINT
Best forContainers that are a single toolContainers with flexible defaults
# Pattern: ENTRYPOINT + CMD for flexible defaults
ENTRYPOINT ["node"]
CMD ["server.js"]

# docker run myapp = node server.js
# docker run myapp worker.js = node worker.js
# docker run myapp --version = node --version

Security Hardening

Run as Non-Root User

By default, Docker containers run as root. If an attacker escapes the container, they have root access to the host. Always create and switch to a non-root user.

# For Alpine-based images
RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -s /bin/sh -D appuser

# For Debian/Ubuntu-based images
RUN groupadd -r appgroup && \
    useradd -r -g appgroup -s /sbin/nologin appuser

# Set ownership and switch user
COPY --chown=appuser:appgroup . /app
USER appuser
Never embed secrets in your Dockerfile: Do not use ENV or ARG for passwords, API keys, or tokens. They persist in the image layers and can be extracted with docker history. Use Docker secrets, bind mounts, or runtime environment variables instead.

HEALTHCHECK Instruction

The HEALTHCHECK instruction tells Docker how to determine if your container is functioning correctly. Orchestrators like Docker Swarm and Kubernetes use this to route traffic and restart unhealthy containers.

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

# For Alpine (no curl by default, use wget)
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

Real-World Example: PHP Laravel Application

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

# Stage 2: Frontend assets
FROM node:20-alpine AS frontend
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build

# Stage 3: Production image
FROM php:8.3-fpm-alpine
WORKDIR /var/www/html

# Install PHP extensions
RUN apk add --no-cache libpng-dev libjpeg-turbo-dev && \
    docker-php-ext-configure gd --with-jpeg && \
    docker-php-ext-install pdo_mysql gd opcache && \
    apk del libpng-dev libjpeg-turbo-dev

# Copy application code
COPY . .
COPY --from=vendor /app/vendor ./vendor
COPY --from=frontend /app/public/build ./public/build

# Configure permissions
RUN chown -R www-data:www-data storage bootstrap/cache && \
    chmod -R 775 storage bootstrap/cache

USER www-data
EXPOSE 9000
CMD ["php-fpm"]

Real-World Example: Go Application

Go applications benefit enormously from multi-stage builds because Go produces statically linked binaries that need no runtime dependencies:

# Stage 1: Build the Go binary
FROM golang:1.24-alpine AS builder
WORKDIR /src

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

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

# Stage 2: Minimal runtime (scratch = empty image)
FROM scratch

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]
Image size comparison for Go: A Go application built with golang:1.24 as the final image would be ~800MB. With alpine it is ~15MB. With scratch it is just your binary size — typically 5-20MB. That is a 40-160x size reduction!

Image Labeling with OCI Annotations

LABEL org.opencontainers.image.title="My Application"
LABEL org.opencontainers.image.description="A web application"
LABEL org.opencontainers.image.version="1.0.0"
LABEL org.opencontainers.image.authors="[email protected]"
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.created="2026-01-01T00:00:00Z"

Image Scanning

Before pushing an image to production, scan it for known vulnerabilities:

# Using Docker Scout (built-in)
$ docker scout cves myapp:latest

# Using Trivy (open source)
$ trivy image myapp:latest
myapp:latest (alpine 3.19)
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
No vulnerabilities found!

Build and Push Checklist

  • Multi-stage build separates build tools from runtime
  • Base image is the smallest viable option (alpine, slim, or distroless)
  • Non-root user is configured with USER instruction
  • .dockerignore excludes unnecessary files
  • RUN instructions are combined to minimize layers
  • Package manager cache is cleaned in the same RUN layer
  • COPY instructions are ordered for optimal cache usage
  • HEALTHCHECK is defined for orchestrator integration
  • No secrets are embedded in the image
  • Image is scanned for vulnerabilities before pushing
Panelica Docker Integration: Panelica's Docker module supports custom images — build from Dockerfile, push to registries, and deploy containers directly from the panel. Manage your entire container lifecycle through a visual interface while maintaining full control over your Dockerfiles and build process.

Conclusion

Building optimized Docker images is a skill that pays dividends on every deployment. Smaller images download faster, start quicker, use less storage, and present a smaller attack surface. The techniques covered here — multi-stage builds, careful layer ordering, minimal base images, and non-root users — are not optional niceties but production essentials.

Start with a simple Dockerfile that works, then iteratively apply these optimizations. Use docker history and docker image ls to measure the impact of each change. The goal is not to achieve the absolute smallest image possible, but to find the right balance between image size, build time, and maintainability for your specific application. A well-built Docker image is one you can build, scan, and deploy with confidence.

Security-first hosting panel

Hosting management, the modern way.

Panelica is a modern, security-first hosting panel — isolated services, built-in Docker and AI-assisted management, with one-click migration from any panel.

Zero-downtime migration Fully isolated services Cancel anytime
Share:
How secure is your hosting panel?