Skip to content
DBDeependra Bhatta~/notes
Docker#container-registry · #docker · #harbor · #dockerfile · #security

Dockerfile Instructions, Best Practices and Harbor Registry

Every Dockerfile instruction in one table, an annotated multi-stage build, image best practices, and pushing images to a private Harbor registry and Docker Hub.

· updated · 11 min read
ON THIS PAGE

A well-structured Dockerfile decides how large, how secure and how fast to rebuild every image a team ships. This guide covers each Dockerfile instruction, walks through an annotated multi-stage build, and lists the practices that keep images small and safe.

The second half deploys a private Harbor registry and pushes images to both Harbor and Docker Hub, so the finished image lands in a registry you control.

Prerequisites

  • Docker Engine installed, as described in part 2
  • For the Harbor section, a Linux VM with Docker Engine and Docker Compose
  • A Docker Hub account for the final section

How a Dockerfile becomes an image

A Dockerfile is a text file of build steps. docker build reads it from top to bottom and runs each step:

  • Each instruction adds a layer to the image.
  • Docker caches layers. On a rebuild it reuses every layer up to the first step that changed, then rebuilds everything after it. Step order therefore matters: put steps that rarely change (installing dependencies) before steps that change often (copying source code).
  • Lines starting with # are comments.

Dockerfile instructions at a glance

InstructionWhat it doesExample
FROMStarts a build stage and sets its base image. Every Dockerfile starts with it (only ARG may come before). AS name names the stage.FROM node:22.16.0-alpine3.22 AS build
RUNRuns a command at build time and saves the result as a new layerRUN apt-get update && apt-get install -y curl
COPYCopies files from the build context, or from another stage with --fromCOPY --from=build /app/dist /usr/share/nginx/html
ADDLike COPY, but can also fetch URLs and Git repos and auto-extracts local tar filesADD https://example.com/archive.tar.gz /src/
WORKDIRSets the working directory for later instructions; creates it if missing. Relative paths build on the previous WORKDIR.WORKDIR /app
ENVSets an environment variable for later build steps and for the running containerENV NODE_ENV=production
ARGDefines a build-time variable, set with --build-arg. Not present in the running container.ARG APP_VERSION=1.0
EXPOSEDocuments which port the app listens on (TCP by default). It does not publish the port.EXPOSE 80/tcp
CMDDefault command (or default arguments to ENTRYPOINT) when the container starts. Only the last CMD counts, and docker run image <cmd> overrides it.CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINTMakes the container run as a fixed executable; CMD then supplies default argumentsENTRYPOINT ["top", "-b"]
USERSets the user (and optional group) for later RUN steps and for the container's processUSER appuser
VOLUMEMarks a path as a mount point for external storageVOLUME /var/lib/data
HEALTHCHECKTells Docker how to test that the app still works. Status goes starting → healthy or unhealthy.HEALTHCHECK CMD curl -f http://localhost/ || exit 1
LABELAdds key-value metadata, visible with docker inspectLABEL org.opencontainers.image.authors="me@example.com"
SHELLChanges the shell used by shell-form commands (default /bin/sh -c on Linux, cmd /S /C on Windows)SHELL ["powershell", "-command"]
STOPSIGNALSignal sent to stop the container (default SIGTERM)STOPSIGNAL SIGQUIT
ONBUILDRegisters a step that runs later, when another image uses this one in FROM. Useful for base images.ONBUILD COPY . /app
MAINTAINERDeprecated. Use LABEL org.opencontainers.image.authors instead.

Exec form vs shell form

CMD, ENTRYPOINT and RUN accept two forms:

  • Exec form, a JSON array: CMD ["nginx", "-g", "daemon off;"]. The program runs directly as process 1 and receives stop signals. Prefer this for CMD and ENTRYPOINT.
  • Shell form, a plain string: CMD nginx -g "daemon off;". It runs through /bin/sh -c, so variables and pipes work, but the shell, not your app, receives the stop signal.

ENTRYPOINT and CMD work together:

DKRDockerfile
FROM ubuntu
ENTRYPOINT ["top", "-b"]
CMD ["-c"]

docker run image runs top -b -c. docker run image -n 1 runs top -b -n 1, because arguments after the image name replace CMD but not ENTRYPOINT.

COPY vs ADD, ARG vs ENV

  • Use COPY by default. Use ADD only when you need its extras (a remote URL, a Git repo, or auto-extracting a local tar file), so its behavior stays explicit.
  • ARG exists only during the build; ENV is baked into the image. Neither is safe for secrets: both can be read from the image or its history. For build-time secrets use RUN --mount=type=secret, and pass runtime secrets when the container starts.

An annotated multi-stage Dockerfile

A multi-stage build uses one stage with all the build tools and a second, small stage that only receives the finished output. The following Dockerfile builds a frontend app with Node and serves it with nginx:

DKRDockerfile
# Stage 1: build the app with Node (this stage is thrown away)
FROM node:22.16.0-alpine3.22 AS build
WORKDIR /app
 
# Copy only the dependency files first, so this layer stays cached
# until package.json or package-lock.json changes
COPY package.json package-lock.json ./
RUN npm ci
 
# Now copy the source and build it
COPY . .
RUN npm run build
 
# Stage 2: serve the static files with nginx
FROM nginx:alpine AS deploy
LABEL org.opencontainers.image.title="frontend"
 
# Take only the build output from stage 1; Node and node_modules stay behind
COPY --from=build /app/dist /usr/share/nginx/html
 
EXPOSE 80
 
# busybox wget is available in Alpine images
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -q --spider http://localhost/ || exit 1
 
# No CMD needed: the nginx base image already starts nginx in the foreground

The output folder (dist here) depends on the build tool, so confirm the path for your project. Build and run the image:

terminal
$ docker build -t frontend:1.0 .
$ docker run -d --name frontend -p 8080:80 frontend:1.0
$ docker ps --filter name=frontend

After about 30 seconds the STATUS column shows (healthy). The final image contains nginx and the static files only.

Image build best practices

Choose a small, trusted base image. Start from Docker Official Images or Verified Publisher images. Alpine or -slim variants are much smaller than full distributions.

Docker Hub search results showing alpine, nginx and busybox marked as Docker Official Image

Pin versions. FROM nginx means nginx:latest, which moves to new releases without notice and can break a previously working build. Use a specific tag, such as nginx:1.27-alpine, or a digest for full reproducibility.

Order steps for the cache. Copy dependency manifests and install dependencies before copying the rest of the source, as in the example above.

Use multi-stage builds. Build tools, compilers and test dependencies stay in the build stage. If several images share setup, put it in a common stage and build from it.

Keep a .dockerignore. It stops files from entering the build context, which makes builds faster and keeps secrets out of the image:

TXT.dockerignore
.git
node_modules
.env
*.md

Install only what you need, and clean up in the same layer. Combine related commands in one RUN, sort long package lists alphabetically, and delete the apt cache in that same step (a later RUN rm would not shrink the earlier layer):

DKRDockerfile
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    git \
    unzip \
  && rm -rf /var/lib/apt/lists/*

Do not run as root. Create a user and switch to it with USER so a compromised app has fewer rights:

DKRDockerfile
RUN useradd --create-home appuser
USER appuser

Keep secrets out of ENV and ARG. Use build secrets and runtime injection, as described in the ARG vs ENV section.

Add a HEALTHCHECK. A process can be running while the app inside is stuck. A health check lets Docker, Compose and Swarm detect that state.

One concern per container. Run the web app, the database and the cache in separate containers. They are then easier to scale, update and reuse.

Treat containers as ephemeral. Design each container so it can be stopped, deleted and replaced with no manual setup. Keep state in volumes or external services.

Rebuild often. An image is a snapshot of its base image and packages at build time. Rebuild regularly to pick up security fixes, skipping the cache and pulling a fresh base:

terminal
$ docker build --pull --no-cache -t my-image:my-tag .

Running a private registry with Harbor

Harbor is an open source registry. On top of storing images, it adds projects with role-based access control, vulnerability scanning and image signing. I set it up on a VM as my private registry.

Install Harbor

Harbor runs as a set of containers, so the VM needs Docker Engine and Docker Compose first. Download and extract the offline installer from the Harbor releases page:

terminal
$ wget https://github.com/goharbor/harbor/releases/download/v2.13.1/harbor-offline-installer-v2.13.1.tgz
$ tar xzvf harbor-offline-installer-v2.13.1.tgz
$ cd harbor

Harbor requires HTTPS. Generate a self-signed CA and server certificate for the hostname harbor.registry.local, following Configure HTTPS Access to Harbor. For a public hostname, use a Let's Encrypt certificate instead. Then copy harbor.yml.tmpl to harbor.yml, set hostname and the certificate paths, and run ./install.sh.

Automated install script

To make the setup repeatable, I wrote a script that installs Docker, creates the certificates, configures Harbor and deploys it. It is available on GitHub: docker_harbor_registry_installation.sh.

GitHub repo DevOps/Docker/Harbor_Registry folder with the docker_harbor_registry_installation.sh script highlighted

Clone the repo or copy the script, and run it with sudo. Without root rights it fails with permission errors.

Log in to the web UI

Open https://<vm-ip-address> in a browser and log in as admin with the default password Harbor12345 (set in harbor.yml). Change it immediately after the first login.

Push an image from another machine

  1. Make the hostname resolve. On each client, add the Harbor VM's IP to /etc/hosts:

    TXThosts
    etchosts
    <harbor-vm-ip>  harbor.registry.local
  2. Trust the self-signed certificate. Docker looks for registry CA certificates in /etc/docker/certs.d/<registry-hostname>/:

    terminal
    $ sudo mkdir -p /etc/docker/certs.d/harbor.registry.local
    $ sudo cp ca.crt /etc/docker/certs.d/harbor.registry.local/
  3. Log in with your Harbor username and password:

    terminal
    $ docker login harbor.registry.local
  4. Create a project in the Harbor UI. Harbor shows the push command for it. Tag the image with the registry hostname and project, then push:

    terminal
    $ docker image tag nginx:latest harbor.registry.local/<project>/nginx:v1
    $ docker image push harbor.registry.local/<project>/nginx:v1

Pushing to Docker Hub

Docker Hub is Docker's public registry and the default for docker pull. Free accounts get unlimited public repositories. To push:

  1. Create an account and a repository, for example deependrabhatta/practice.

  2. Tag a local image with that repository name:

    terminal
    $ docker image tag nginx:latest deependrabhatta/practice:v1

    nginx:latest is the local image; deependrabhatta/practice:v1 is the Docker Hub repository and tag.

  3. Log in and push:

    terminal
    $ docker login
    $ docker image push deependrabhatta/practice:v1

docker login shows a one-time code and a URL. Open the URL, confirm the code, and the CLI is logged in. For scripts and CI, create a personal access token under Account settings → Personal access tokens and use it in place of a password.

Common mistakes

  • x509: certificate signed by unknown authority on docker login: the client does not trust Harbor's self-signed CA. Copy ca.crt into /etc/docker/certs.d/harbor.registry.local/.
  • denied: requested access to the resource is denied on push: the image is not tagged with the registry and project (or Docker Hub username), or you are not logged in.
  • Cache never used: COPY . . comes before the dependency install, so every source change reinstalls all dependencies.
  • Image bigger than expected: build tools left in the final image. Move them into a build stage.

Key takeaways

  • Every instruction is a layer, and the cache is reused only up to the first change, so order steps from least to most often changed.
  • Use exec form for CMD and ENTRYPOINT, COPY over ADD, and never put secrets in ENV or ARG.
  • Multi-stage builds, pinned small base images, .dockerignore and a non-root USER give smaller, safer images.
  • Harbor provides a private registry with access control and scanning; clients need DNS for the hostname and its CA certificate.
  • To push anywhere, tag the image as registry/project/name:tag first.

Next in this series: Docker Compose.