In this lesson: Write a Dockerfile and build and run an image from it.
A Dockerfile is the recipe. Each line is a step, and running docker build follows them to produce an image. It replaces the page of setup instructions that was always slightly out of date.
A working example
FROM php:8.2-fpm-alpine
# System packages the application needs
RUN apk add --no-cache git unzip libzip-dev \
&& docker-php-ext-install pdo_mysql zip
# Where everything lives inside the image
WORKDIR /app
# Dependencies first — see the next lesson for why
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --optimize-autoloader
# Then the application itself
COPY . .
# Do not run as root
RUN chown -R www-data:www-data /app
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
docker build -t yanjye:latest .
docker run -d --name yanjye -p 8080:9000 yanjye:latest
What each instruction does
| Instruction | Meaning |
|---|---|
FROM | The image you start from. Everything below builds on it. |
RUN | Run a command while building. Its result becomes part of the image. |
WORKDIR | Set the current directory for everything after it. |
COPY | Copy files from your project into the image. |
ENV | Set an environment variable, at build time and at run time. |
USER | Which user the following steps — and the container — run as. |
EXPOSE | Documentation: this is the port the app listens on. It publishes nothing by itself. |
CMD | What to run when a container starts. Overridable on the command line. |
ENTRYPOINT | The fixed command; CMD becomes its default arguments. |
Pin your base image
FROM php:8.2-fpm-alpine # good — a specific version
FROM php:latest # a build that worked in March breaks in June
latest is not a version, it is a moving pointer. A build that is reproducible today and different tomorrow is not reproducible, and you will discover the change at the worst moment.
Choose a small base
| Base | Rough size | Notes |
|---|---|---|
ubuntu | ~80 MB | Familiar, full package manager. |
debian-slim | ~30 MB | A good default. |
alpine | ~7 MB | Tiny. Uses musl instead of glibc, which occasionally breaks binaries. |
distroless | ~2 MB | No shell at all. Very secure, harder to debug. |
.dockerignore
COPY . . copies everything, including things you do not want in the image at all.
.git
node_modules
vendor
.env
storage/logs
*.md
tests
COPY a .env into an image. Images get pushed to registries and shared. Anyone who pulls it can run docker history and read what went in. Configuration is passed at run time, not baked in.
Do not run as root
By default a container runs as root. If your application is compromised, the attacker is root inside the container — and that is the starting point for escaping to the host. Two extra lines remove the whole class of problem:
RUN adduser -D -u 1000 app
USER app
One process per container
Do not put your web server, your application and your database in one image. Each container should do one job, so that each can be restarted, scaled and updated on its own. If a container needs a process manager to keep three things alive, it should have been three containers.
Try it yourself
Write a Dockerfile for the smallest thing you have — even a static site served by nginx. Build it, run it, open it in a browser. Then run docker history <your-image> and read the layers: every line of your Dockerfile is there, with the size it added.