Dockerfile Best Practices für ProductionDockerfile Best Practices for Production
Ein Dockerfile kann eine Anwendung zuverlässig ausliefern – oder bei jedem Build Zeit, Speicher und Vertrauen kosten. Für lokale Experimente reicht oft ein Image, das irgendwie startet. In Production zählt mehr: Der Build muss wiederholbar sein, das Image nur Laufzeit-Code enthalten und ein kompromittierter Prozess möglichst wenig dürfen.
Dieser Leitfaden richtet sich an Einsteiger und konzentriert sich auf die Maßnahmen mit dem größten Effekt. Nicht jede Anwendung braucht jede Optimierung. Ein sauberer Ausgangspunkt braucht aber keine komplizierte Architektur.
Erst verstehen: Was landet im Image?
Ein Dockerfile baut Schicht für Schicht. Jede COPY- und RUN-Anweisung hinterlässt eine Schicht; das finale Image enthält deren Dateisystemzustand. Daraus folgen drei praktische Regeln:
- Alles im Build-Kontext kann versehentlich ins Image geraten.
- Tools aus der Build-Phase bleiben ohne Trennung im Runtime-Image.
- Eine frühe Änderung kann den Cache späterer Schritte ungültig machen.
Das Dockerfile beeinflusst damit Image-Größe, Build-Zeit, Angriffsfläche und die Frage, ob derselbe Commit morgen noch dasselbe Artefakt erzeugt.
Kleine Base Images – aber nicht blind Alpine
Ein vollständiges Ubuntu ist selten nötig. Für viele Anwendungen ist ein schlankes, gepflegtes Basis-Image ein besserer Start:
slim spart Ballast gegenüber einem vollständigen Distribution-Image und bleibt für viele native Node-Module kompatibler als Alpine. Alpine kann sehr klein sein, verwendet aber musl statt glibc. Das ist kein Problem per se, führt bei manchen nativen Abhängigkeiten aber zu Extraarbeit.
Die Regel lautet daher nicht „immer Alpine“, sondern: Nimm das kleinste Image, das zur Anwendung und ihren Abhängigkeiten passt. Weniger installierte Software bedeutet weniger Code zum Patchen und eine kleinere Angriffsfläche – nicht automatisch null Sicherheitslücken.
Kein latest: Versionen und Digests festlegen
latest ist kein Release-Kanal mit Garantien. Der Tag kann morgen auf eine andere Node-Version zeigen. Ein Build, der gestern lief, kann dann ohne Änderung am eigenen Code brechen.
Mindestens die konkrete Version angeben:
Für vollständig identische Base-Image-Bytes wird zusätzlich ein Digest gepinnt:
Das ist bewusst strenger: Sicherheitsupdates kommen dann nicht automatisch mit. Deshalb Digests regelmäßig geprüft aktualisieren, statt sie einmal festzuschreiben und zu vergessen.
Multi-Stage Builds: Bauen und Ausführen trennen
Compiler, Testwerkzeuge und Development-Abhängigkeiten braucht ein Server zur Laufzeit nicht. Multi-Stage Builds trennen genau diese Aufgaben.
Ein typisches Problem sieht so aus:
Das finale Image enthält Quellcode, npm-Cache, Build-Umgebung und meist auch Development-Abhängigkeiten. Besser ist eine eigene Laufzeit-Stufe:
&&
&&
Die Build-Stufe darf alles Nötige enthalten. Die Runtime-Stufe erhält nur Build-Ausgabe und Produktions-Abhängigkeiten. Das verkleinert das Artefakt und verhindert, dass versehentlich Compiler oder Tests im Servercontainer landen.
Bei statischen Websites oder kompilierten Go- und Rust-Programmen fällt die Runtime-Stufe oft noch kleiner aus: Es werden nur das erzeugte Binary oder die statischen Dateien kopiert.
Docker-Cache gezielt ausnutzen
Docker kann einen Schritt wiederverwenden, wenn sich die vorherigen Schichten nicht geändert haben. Darum kommen Abhängigkeitsdateien vor dem restlichen Quellcode:
Eine Änderung an einer README oder einer einzelnen Source-Datei führt so nicht zu einer neuen Paketinstallation. Erst bei Änderungen an package.json oder Lockfile wird npm ci erneut ausgeführt.
Das gleiche Muster gilt für andere Ökosysteme: Cargo.toml und Cargo.lock vor Rust-Quellcode, requirements.txt vor Python-Code. Cache-Optimierung muss lesbar bleiben. Ein komplexer Trick, der nur wenige Sekunden spart, ist selten die Wartung wert.
.dockerignore begrenzt den Build-Kontext
Bevor Docker die erste Zeile verarbeitet, sendet der Client den Build-Kontext an den Builder. Ohne .dockerignore können Git-Historie, lokale Abhängigkeiten, Testartefakte oder eine .env-Datei dort landen.
Für ein Node-Projekt ist das ein brauchbarer Ausgangspunkt:
.git
.github
node_modules
coverage
.env
.env.*
!.env.example
*.log
.vscode
.idea
node_modules gehört fast nie in den Kontext: Es wird im Image für dessen Linux-Architektur installiert. Lokale Module von macOS oder Windows sind dafür ohnehin die falsche Grundlage.
Die Datei ist kein Geheimnisschutz. Ein Secret, das bereits per COPY . . in eine frühere Schicht gelangt ist, bleibt in der Image-Historie. Secrets gehören deshalb nicht in das Projektverzeichnis, das gebaut wird.
COPY statt ADD
Für normale Dateien und Verzeichnisse ist COPY die klare Wahl:
ADD kann zusätzlich lokale Archive automatisch entpacken und URLs laden. Diese Sonderfunktionen machen die Anweisung schwerer vorhersehbar und sind hier nicht nötig.
ADD ist nicht verboten. Es ist passend, wenn das automatische Entpacken eines lokalen Tar-Archivs wirklich beabsichtigt ist. Für den üblichen Quellcode-Kopiervorgang beschreibt COPY die Absicht genauer.
Nicht als Root starten
Container laufen standardmäßig oft als Root. Das macht einen Fehler in der Anwendung nicht automatisch zu einem Host-Angriff, gibt dem Prozess im Container aber mehr Rechte als nötig.
Lege einen dedizierten Benutzer an und wechsle vor dem Start:
&&
COPY --chown verhindert den häufigen Folgefehler: Der Prozess läuft zwar als unprivilegierter Benutzer, kann seine Dateien oder Log-Verzeichnisse aber nicht lesen beziehungsweise schreiben.
Für Ports unter 1024, Paketinstallationen und manche Build-Schritte sind Root-Rechte nötig. Diese gehören vor USER app, nicht in die Laufzeit der Anwendung.
Secrets weder in ENV noch in Build-Args
Folgendes gehört nicht in ein Dockerfile:
Auch ARG DB_PASSWORD=... ist kein sicherer Ersatz. Build-Argumente können in Metadaten, Logs oder Build-Historie sichtbar werden. Ebenso problematisch: COPY . ., wenn eine lokale .env oder ein Schlüssel mitkopiert wird.
Zur Laufzeit kommen Secrets über den Deployment-Mechanismus: etwa Kubernetes Secrets, Docker Swarm Secrets oder eine Secret-Verwaltung des Cloud-Anbieters. Für ein Secret, das ausschließlich beim Build benötigt wird, nutzt BuildKit einen temporären Mount:
Das Secret wird bei diesem Schritt bereitgestellt, aber nicht in die erzeugte Image-Schicht kopiert. Zugangsdaten müssen außerdem nach einem möglichen Leak rotiert werden; ein neues Dockerfile entfernt keinen bereits veröffentlichten Layer.
Weniger Pakete, saubere Paketinstallation
Jedes zusätzliche Paket bringt Größe, Updates und potenzielle CVEs. Installiere daher nur, was der jeweilige Build-Schritt braucht. Bei Debian-Images gehört Aktualisieren, Installieren und Aufräumen in dieselbe RUN-Anweisung:
&& &&
Hier ist das Aufräumen wichtig, weil die Paketlisten sonst in der Schicht bleiben. „RUN-Befehle immer zusammenfassen“ ist dagegen keine allgemeine Regel: Zusammenfassen nur, wenn die Befehle einen temporären Zustand teilen – wie Paketlisten und deren Cleanup. Gute Lesbarkeit bleibt wichtiger als Layer-Akrobatik.
WORKDIR statt cd
WORKDIR setzt das Verzeichnis für alle folgenden Anweisungen und erstellt es bei Bedarf:
Das ist robuster als ein RUN cd /app, denn ein cd gilt nur innerhalb dieses einen Shell-Prozesses. Absolute Pfade bleiben dadurch die Ausnahme statt Pflicht.
Healthchecks passend zur Plattform einsetzen
Ein laufender Prozess ist nicht zwingend eine funktionierende Anwendung. Ist ein HTTP-Endpunkt vorhanden, kann ein Docker-Healthcheck ihn prüfen:
Der Endpunkt sollte keine Datenbankmigration auslösen und keine fremden Dienste teuer abfragen. Ein Healthcheck soll schnell beantworten, ob diese Instanz Anfragen bedienen kann.
Wichtig für Kubernetes: Kubernetes wertet die HEALTHCHECK-Anweisung eines Dockerfiles nicht aus. Dort gehören Liveness-, Readiness- und bei langsamen Starts Startup-Probes in das Deployment-Manifest.
Das gebaute Image prüfen
Ein Dockerfile kann gut aussehen und trotzdem mehr enthalten als erwartet. Prüfe daher das Artefakt, nicht nur den Text. Nach dem Build zeigen diese Befehle Größe und Schichten:
In der Historie sollten keine Zugangsdaten, lokalen Pfade oder unerwarteten Download-Befehle auftauchen. docker image inspect my-app zeigt unter anderem den konfigurierten Benutzer, Umgebungsvariablen und den Startbefehl. Das ist besonders nützlich, wenn ein USER-Wechsel zwar im Dockerfile steht, aber in einer späteren Stage verloren ging.
Danach den Container mit denselben Variablen und Ports wie im Deployment starten. Prüfe einen echten Request, die Logs und den Health-Status:
Der zweite Befehl funktioniert nur mit definiertem HEALTHCHECK. Fehlt einer, ist das kein Grund, einen künstlichen Endpunkt einzubauen. Nicht jede Anwendung stellt HTTP bereit. Bei Worker-Prozessen oder CLI-Jobs sind Exit-Code, Queue-Verbindung oder die Probe des Orchestrators oft die passendere Betriebsprüfung.
Checkliste vor dem Push
- Base Image mit konkreter Version, bei Bedarf mit Digest
.dockerignoreenthält lokale Abhängigkeiten, Git-Daten und Secrets- Build- und Runtime-Umgebung sind getrennt
- Runtime enthält keine Development-Abhängigkeiten und läuft nicht als Root
- Lockfile wird mit
npm ci,pip --require-hashesoder dem jeweiligen Äquivalent genutzt - Secrets kommen zur Laufzeit oder über BuildKit, nie per
COPY,ENVoderARG - Healthcheck oder Orchestrator-Probes prüfen die tatsächliche Erreichbarkeit
Ein gutes Dockerfile ist kein Selbstzweck. Es sorgt dafür, dass dieselbe Anwendung mit weniger Überraschungen gebaut, ausgeliefert und betrieben wird. Starte mit kleinem Base Image, Multi-Stage Build, .dockerignore, Lockfile und Non-Root-User. Der Rest folgt aus den Anforderungen der Anwendung.
Weiterführende Quellen
Dockerfile Best Practices for Production
A Dockerfile can deliver an application reliably—or cost time, memory, and trust on every build. For local experiments, an image that somehow starts is often enough. In production, more matters: the build must be repeatable, the image must contain only runtime code, and a compromised process should be able to do as little as possible.
This guide is aimed at beginners and focuses on the measures with the greatest impact. Not every application needs every optimization. A clean starting point does not, however, require a complicated architecture.
First, understand: what lands in the image?
A Dockerfile builds layer by layer. Every COPY and RUN instruction leaves a layer; the final image contains their filesystem state. Three practical rules follow:
- Anything in the build context can accidentally end up in the image.
- Tools from the build phase remain in the runtime image unless you separate them.
- An early change can invalidate the cache of later steps.
The Dockerfile therefore affects image size, build time, attack surface, and whether the same commit still produces the same artifact tomorrow.
Small base images—but not blindly Alpine
A full Ubuntu is rarely necessary. For many applications, a slim, maintained base image is a better start:
slim saves bloat compared with a full distribution image and stays more compatible with many native Node modules than Alpine. Alpine can be very small, but it uses musl instead of glibc. That is not a problem per se, but it adds work for some native dependencies.
The rule is therefore not “always Alpine”, but: choose the smallest image that fits the application and its dependencies. Less installed software means less code to patch and a smaller attack surface—not automatically zero vulnerabilities.
No latest: pin versions and digests
latest is not a release channel with guarantees. The tag can point to a different Node version tomorrow. A build that ran yesterday can then break without any change to your own code.
At least specify the concrete version:
For fully identical base-image bytes, additionally pin a digest:
This is deliberately stricter: security updates then do not arrive automatically. That is why you should check and update digests regularly instead of pinning them once and forgetting them.
Multi-stage builds: separate building from running
Compilers, test tools, and development dependencies are not needed by a server at runtime. Multi-stage builds separate exactly these tasks.
A typical problem looks like this:
The final image contains source code, the npm cache, the build environment, and usually development dependencies too. A dedicated runtime stage is better:
&&
&&
The build stage may contain everything necessary. The runtime stage receives only the build output and production dependencies. That shrinks the artifact and prevents compilers or tests from accidentally ending up in the server container.
For static websites or compiled Go and Rust programs, the runtime stage is often even smaller: only the produced binary or the static files are copied.
Leverage the Docker cache deliberately
Docker can reuse a step when the previous layers have not changed. That is why dependency files come before the rest of the source code:
A change to a README or a single source file thus does not trigger a new package installation. Only when package.json or the lockfile changes is npm ci run again.
The same pattern applies to other ecosystems: Cargo.toml and Cargo.lock before Rust source, requirements.txt before Python code. Cache optimization must stay readable. A complex trick that saves only a few seconds is rarely worth maintaining.
.dockerignore limits the build context
Before Docker processes the first line, the client sends the build context to the builder. Without a .dockerignore, git history, local dependencies, test artifacts, or a .env file can end up there.
For a Node project this is a usable starting point:
.git
.github
node_modules
coverage
.env
.env.*
!.env.example
*.log
.vscode
.idea
node_modules almost never belongs in the context: it is installed in the image for its Linux architecture. Local modules from macOS or Windows are the wrong basis for it anyway.
The file is not secret protection. A secret that has already entered an earlier layer via COPY . . stays in the image history. Secrets therefore do not belong in the project directory that is built.
COPY instead of ADD
For ordinary files and directories, COPY is the clear choice:
ADD can additionally unpack local archives automatically and load URLs. These special features make the instruction harder to predict and are unnecessary here.
ADD is not forbidden. It is appropriate when automatically unpacking a local tar archive is genuinely intended. For the usual source-copying process, COPY describes the intent more precisely.
Do not run as root
Containers often run as root by default. That does not automatically turn a bug in the application into a host attack, but it gives the process in the container more privileges than necessary.
Create a dedicated user and switch before starting:
&&
COPY --chown prevents the common follow-up error: the process runs as an unprivileged user but cannot read or write its own files or log directories.
Root privileges are needed for ports below 1024, package installation, and some build steps. Those belong before USER app, not in the application runtime.
Secrets neither in ENV nor in build args
The following does not belong in a Dockerfile:
ARG DB_PASSWORD=... is not a safe replacement either. Build arguments can become visible in metadata, logs, or build history. Equally problematic is COPY . . when a local .env or a key is copied along.
At runtime, secrets come through the deployment mechanism: for example Kubernetes secrets, Docker Swarm secrets, or the cloud provider's secret management. For a secret needed only at build time, BuildKit uses a temporary mount:
The secret is made available for this step but is not copied into the generated image layer. Credentials must also be rotated after a possible leak; a new Dockerfile does not remove an already published layer.
Fewer packages, clean package installation
Every additional package brings size, updates, and potential CVEs. Install only what the respective build step needs. On Debian images, updating, installing, and cleaning up belong in the same RUN instruction:
&& &&
The cleanup matters here because the package lists otherwise stay in the layer. “Always combine RUN commands” is not a general rule, by contrast: combine them only when the commands share temporary state, such as package lists and their cleanup. Good readability stays more important than layer acrobatics.
WORKDIR instead of cd
WORKDIR sets the directory for all following instructions and creates it if needed:
That is more robust than a RUN cd /app, because cd applies only within that one shell process. Absolute paths thereby remain the exception rather than the rule.
Use health checks suited to the platform
A running process is not necessarily a working application. If an HTTP endpoint exists, a Docker health check can probe it:
The endpoint should not trigger a database migration or expensively query external services. A health check should quickly answer whether this instance can serve requests.
Important for Kubernetes: Kubernetes does not evaluate the HEALTHCHECK instruction of a Dockerfile. There, liveness, readiness, and—for slow starts—startup probes belong in the deployment manifest.
Verify the built image
A Dockerfile can look good and still contain more than expected. So inspect the artifact, not just the text. After the build, these commands show size and layers:
The history should show no credentials, local paths, or unexpected download commands. docker image inspect my-app shows, among other things, the configured user, environment variables, and the start command. That is especially useful when a USER change appears in the Dockerfile but got lost in a later stage.
Then start the container with the same variables and ports as in deployment. Verify a real request, the logs, and the health status:
The second command works only with a defined HEALTHCHECK. If one is missing, that is no reason to add a contrived endpoint. Not every application exposes HTTP. For worker processes or CLI jobs, the exit code, queue connection, or the orchestrator's probe is often the more appropriate operational check.
Checklist before pushing
- Base image with a concrete version, with a digest if needed
.dockerignorecontains local dependencies, git data, and secrets- Build and runtime environments are separated
- Runtime contains no development dependencies and does not run as root
- Lockfile is used with
npm ci,pip --require-hashes, or the respective equivalent - Secrets come at runtime or via BuildKit, never via
COPY,ENV, orARG - A health check or orchestrator probes verify actual reachability
A good Dockerfile is not an end in itself. It ensures that the same application is built, delivered, and operated with fewer surprises. Start with a small base image, multi-stage build, .dockerignore, lockfile, and a non-root user. The rest follows from the application's requirements.