
Dockerfile best practices: a guide for developers
Tips for developers writing Dockerfiles: WORKDIR, multi-stage builds, base images, ARG and ENV, ENTRYPOINT and CMD, downloads and .dockerignore.
By Víctor Piles TemporalSRE and systems engineerPublished on 5 min read
A Dockerfile is a text file containing the instructions to build a new image. Docker treats each instruction it reads from the file as a separate layer, which can be shared in full with other recipes.
In short: always work inside a WORKDIR, split the build with multi-stage builds so the final image carries only what it needs, create base images with your dependencies, use ARG and ENV to make your recipes reusable, understand the difference between ENTRYPOINT and CMD, download external packages with curl rather than ADD, and use .dockerignore to exclude everything you don’t need.
Developers and Dockerfiles
If you’re a developer, knowing Docker well is essential. Today I’m going through a few useful tips for writing Dockerfiles.
Who should build the images, the developers or the DevOps team?
I’m clear on this one: in my opinion, the developers. They’re the ones who know their code’s dependencies and needs.
As a DevOps/SRE engineer I like to lend a hand, and I hope these tips help you build more stable, higher quality images.
Best practices for writing Dockerfiles
Set the working directory and work inside it
Docker works on top of the WORKDIR, so you don’t need to give absolute paths when copying unless they’re strictly necessary.
FROM php:8.3-apache
WORKDIR /app
COPY . .
Here the whole current directory (PWD) is copied to the /app path in the image.
Use multi-stage builds to copy only the layers you need between images
- With this method you can work with Docker alone: you don’t need any dependency installed on your machine. The multi-stage build does it for you, which makes it much easier to try different versions of the tools you use.
- Declare your code’s whole build cycle in small steps: it ends up far more declarative and functional.
- In the following example, intermediate images are generated to build a final image with the static files that will be served.
FROM node:latest AS install
WORKDIR /app
COPY . .
RUN npm install
#-----------#
FROM install AS build
RUN npm run build
#-----------#
FROM nginx:alpine AS standalone
WORKDIR /usr/share/nginx/html
COPY --from=build --chown=nginx:nginx /app/build .
Here two temporary images are generated, where everything needed is installed and the code is compiled, and the final image serves only the static files.
Build an image with all its dependencies and use it as the base for your other applications
Dockerfile-base (build it with a tag such as base-php):
FROM php:8.3-apache
COPY php.ini-production $PHP_INI_DIR/php.ini
RUN a2enmod alias
Dockerfile:
FROM base-php
WORKDIR /var/www/html
COPY . .
Here a base image is generated with all the dependencies.
Use arguments to create reusable recipes
ARG BASE_IMAGE
FROM $BASE_IMAGE
ARG FILE
WORKDIR /app
COPY --chmod=600 $FILE .
Build it with the following command:
docker build --build-arg BASE_IMAGE=alpine \
--build-arg FILE=deploy.yml .
Here the arguments are used to create recipes that can be shared.
Installing packages
This is a well-formed instruction for installing everything you need:
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y \
nodejs \
python3 \
python3-pip \
curl \
&& rm -rf /var/lib/apt/lists/*
Here all the dependencies are installed in a well-structured way.
Defining variables inside the recipe is not bad practice
Use the variables your application needs to know about in order to build correctly.
FROM node:latest
ARG PUBLIC_URL
ENV URL=$PUBLIC_URL
RUN npm run build
To build this recipe, run the following command:
docker build --build-arg PUBLIC_URL=test-dev .
Here environment variables are used to build the code.
How to use ENTRYPOINT and CMD
Both instructions are used to run commands when the container starts:
- ENTRYPOINT: what the container runs when it starts.
- CMD: arguments appended to the
ENTRYPOINT, or a one-off command run in the container.
FROM alpine
ENTRYPOINT ["sleep"]
CMD ["10"]
FROM alpine
WORKDIR /usr/local/bin/
COPY docker-entrypoint.sh .
RUN chmod +x docker-entrypoint.sh
ENTRYPOINT [ "sh", "/usr/local/bin/docker-entrypoint.sh" ]
Here ENTRYPOINT and CMD are used to run commands when the containers start.
Let Docker download the external packages you need
You may have used the ADD instruction for this before. It’s strongly discouraged for fetching packages from remote URLs: if you download them yourself in a RUN, you can delete the files you no longer need in the same step.
FROM alpine
RUN apk add --no-cache curl \
&& curl -SL https://example.com/test.tar.gz | tar -xz
Here curl is used to download the external packages, without the ADD instruction everyone uses.
Use .dockerignore
Send only what you need and exclude every file that doesn’t have to be sent. The logic is the same as .gitignore.
commands
node_modules
public
scripts
src*
.yml*
.yaml
.env*
.git*
.json
yarn.lock
...
Part two
These are the basics. In part two I cover health checks (HEALTHCHECK), heredoc syntax and BuildKit mounts for secrets and SSH keys.
If you want these practices applied automatically in your deployments, in infrastructure as code, GitOps and CI/CD I explain how I set up the pipelines that build the images, and in vulnerability management how I scan them. If you need anything, get in touch through the contact page.
Originally published on Medium.


