The problem: it worked on my machine#

One of the hardest problems in DevOps is getting an application to behave the same in every environment: local, staging, production, on-premises and cloud. Differences in libraries, OS versions or configuration break deployments that worked on the author's machine.

Containers attack that problem at the root, and the Dockerfile is what makes it work: a versioned recipe that states exactly what your application is built from and how it starts.

This guide covers what a Dockerfile is, why it's especially valuable for external apps and in-house services, how Docker brings consistency across the whole lifecycle, and the best practices that matter in production, with examples you can copy.

Documentation: Docker · Docker overview ↗

What a Dockerfile is and why it matters#

A Dockerfile is a text file of instructions that Docker runs to build an image. In it you define:

  • The base image: Alpine, Debian, Ubuntu, distroless or your language's official image.
  • Runtime versions (Node, Python, Java, Go).
  • The system packages and libraries you need.
  • The application files that get copied in.
  • Environment variables with non-sensitive defaults.
  • The user it runs as and the startup command.

The result is an image: an artifact in the standard OCI format that bundles everything the application needs to run except the kernel, which it shares with the host. You can version that image, scan it, store it in a registry and run it on any compatible platform.

In other words, your application stops depending on how each server happens to be set up and depends instead on an explicit definition you can review in a pull request.

Documentation: Docker · Dockerfile reference ↗ · Docker · Docker overview ↗ · Open Container Initiative · Image spec ↗

The value for external apps: fewer surprises, more portability#

With external applications and custom services (in-house APIs, batch workers, integrations, ETLs, internal dashboards, scrapers, small microservices) the problem gets worse, because they tend to bring:

  • Awkward or very specific dependencies.
  • Unusual binaries or runtimes.
  • Different Python, Node or Java versions living side by side in the same organization.
  • System libraries that have to be installed by hand.
  • Configuration scattered across environments.

Packaging each one in its own image fixes that: the app runs the same on AWS, Google Cloud, Azure, Kubernetes or on-premises, doesn't depend on what's installed on the server, every change is versioned and auditable, you can replicate the exact environment in QA, and a change on the host doesn't break it. Typical cases: services outside the core product, cron jobs and batch tasks, third-party connectors, internal tools and legacy apps you want to move without rewriting.

Documentation: Docker · Docker overview ↗

1. Reproducible builds (and what that really means)#

The original version of this article claimed that two people building the same Dockerfile get an identical image. That isn't quite true: a tag like node:22-alpine moves over time, system packages change and timestamps differ. What you do get is an explicitly defined environment; to get close to reproducible builds you have to pin whatever can move.

dockerfile
# syntax=docker/dockerfile:1
# Pin the base image tag (or its @sha256:... digest)
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# The official Node image ships an unprivileged node user
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]
A Dockerfile for a Node app: dependencies installed from the lockfile in a separate stage, no devDependencies, running as a non-root user.
  • Pin the base image to a specific tag and, if you need maximum stability, to its digest.
  • Install dependencies from the lockfile with npm ci or your language's equivalent.
  • Use a .dockerignore so local node_modules, .git or .env files never enter the build context.
  • Copy dependency manifests before the code so the layer cache works for you.
text
# .dockerignore
.git
node_modules
.env
.env.*
!.env.example
dist
coverage
*.log
A minimal .dockerignore: keep out what must never reach the image.

Documentation: Docker · Pin base image versions ↗ · Docker · Building best practices ↗ · Docker · .dockerignore files ↗ · Docker · Reproducible builds ↗ · Node.js Docker image · Best practices ↗

2. Immutable images#

A published image doesn't change: its layers are content-addressed and the image is identified by its digest. That's the principle behind immutable infrastructure: instead of logging into a server to patch it, you build a new image and replace the containers.

That rules out environments polluted by manual changes, hand-installed dependencies nobody documented, and hidden configuration. Note that a running container's filesystem can still be written to; it's the image that is immutable. Anything changed inside a container is lost when it's replaced, which is exactly the point.

The Twelve-Factor methodology describes this as separating build, release and run: the build produces the artifact once, the release combines it with an environment's config, and run executes it.

Documentation: Open Container Initiative · Image spec ↗ · The Twelve-Factor App · Build, release, run ↗

3. Portability, with an architecture caveat#

The same image runs in Docker on your workstation, on Kubernetes, on Amazon ECS or EKS, on Google Cloud Run, on Azure Container Apps or AKS, and on your own servers. That's the build once, run anywhere promise, and it largely holds because all of those platforms accept OCI-format images.

The caveat: an image is built for a CPU architecture. An arm64-only image built on an Apple Silicon Mac won't start on an x86_64 server, and Cloud Run's contract, for example, requires Linux x86_64 executables. If you deploy to several architectures, such as Raspberry Pi or Graviton instances alongside x86, publish a multi-platform image.

bash
# An immutable semantic version and two architectures in one manifest
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t registry.example.com/my-app:1.3.2 \
  --push .

# Shows the digest and the published platforms
docker buildx imagetools inspect registry.example.com/my-app:1.3.2
A multi-platform build with buildx and an immutable version tag.

Documentation: Docker · Multi-platform builds ↗ · Google Cloud · Cloud Run container runtime contract ↗ · AWS · What is Amazon ECS ↗ · Microsoft · Azure Container Apps overview ↗

4. A natural fit for CI/CD#

Because the image is an artifact, the pipeline can treat it like any other:

  • Build it once per commit.
  • Scan its packages for vulnerabilities.
  • Tag it with a version, such as 1.3.2, or the commit SHA.
  • Push it to a registry (Amazon ECR, Google Artifact Registry, Azure Container Registry, Docker Hub).
  • Promote that exact image from staging to production and deploy it automatically.

The key is to promote the same image between environments rather than rebuilding it for each one. That way what you tested in staging is, bit for bit, what reaches production.

Documentation: The Twelve-Factor App · Build, release, run ↗ · Kubernetes · Images ↗

Best practice: minimal images and multi-stage builds#

Use small base images (Alpine, slim variants or distroless): fewer packages mean a smaller attack surface and fewer pending patches. Keep in mind that Alpine uses musl instead of glibc, which can affect native binaries; if that bites, a Debian slim variant is the usual alternative.

A multi-stage build compiles in a full image and copies only the result into a minimal final image. Build tools never reach production:

dockerfile
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
A static Go binary (CGO_ENABLED=0) in a distroless image that already runs as the nonroot user.

Documentation: Docker · Multi-stage builds ↗ · Google · distroless images ↗ · Docker · Building best practices ↗

Best practice: keep config and secrets out of the image#

The image should hold code and dependencies, not an environment's configuration. That means:

  • Don't copy .env files into the image.
  • Don't set sensitive values with ENV or ARG in the Dockerfile: they stay visible in the image history.
  • Inject config at runtime through environment variables, secrets managers, or Kubernetes ConfigMaps and Secrets.

If the build itself needs a secret, such as a token for a private package registry, use BuildKit build secrets, which are mounted only for that step:

bash
# Dockerfile
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

# The token never ends up in any image layer
docker build --secret id=npmrc,src=$HOME/.npmrc -t my-app:1.3.2 .
Mounting a secret only for the RUN that needs it.

That way one image serves dev, QA, staging and production, and only the external config changes.

Documentation: Docker · Build secrets ↗ · Kubernetes · ConfigMaps ↗ · Kubernetes · Secrets ↗ · The Twelve-Factor App · Config ↗

Best practice: don't run as root#

Unless you say otherwise, the container process runs as root. If someone compromises the application, they get more room to damage the container and, given an escape vulnerability, the host. Create an unprivileged user and switch to it with USER:

dockerfile
# Alpine (BusyBox)
RUN addgroup -S app && adduser -S app -G app
USER app

# Debian / Ubuntu
RUN groupadd --system app && useradd --system --gid app app
USER app
On Alpine, adduser is the BusyBox version; Debian and Ubuntu use useradd.

Many official images already include one, such as node in the Node image or nonroot in distroless. Doing this limits the blast radius of a vulnerability in your dependencies and lines up with common container hardening guidance.

Documentation: Docker · USER instruction ↗ · Docker · Docker Engine security ↗ · Node.js Docker image · Best practices ↗

Best practice: version every build and define health#

Predictable tags make rollback easy: my-app:1.3.2 always points to the same thing, while latest changes with every push. Kubernetes advises against latest in production because it makes it hard to know what's running and to roll back; use version tags or, better, deploy by digest.

Container health is mostly defined by the platform. Docker has a HEALTHCHECK instruction, but Kubernetes ignores it and uses its own liveness, readiness and startup probes; ECS and Cloud Run have their own settings too. Expose a health endpoint in your app and wire it up where it belongs.

Documentation: Kubernetes · Images ↗ · Docker · HEALTHCHECK instruction ↗ · Kubernetes · Liveness, readiness and startup probes ↗

Why cloud platforms adopted containers#

AWS, Google Cloud and Azure all offer managed services whose unit of deployment is a container image. The reasons are practical:

PropertyWhat it gives you
Fast start compared with a VMQuicker horizontal scaling; actual time depends on image size and the app
Isolation by designEach service with its own dependencies, no conflicts between them
Replace, don't patchRolling deploys and rollbacks without downtime when platform and app support it
Standard OCI formatThe same image works across providers

In short, the container image has become the basic unit of modern deployment.

Documentation: AWS · What is Amazon ECS ↗ · Google Cloud · Cloud Run container runtime contract ↗ · Microsoft · Azure Container Apps overview ↗

Production Dockerfile checklist#

  • Base image pinned to a specific tag or digest.
  • Dependencies installed from the lockfile.
  • A .dockerignore that excludes .git, node_modules and .env.
  • Multi-stage build: no compilers in the final image.
  • No config or secrets in the image; build secrets for whatever the build needs.
  • Process running as a non-root user.
  • Immutable version tag, the right architecture, and the same image promoted across environments.

Documentation: Docker · Building best practices ↗

Sources and scope

Documentation checked on September 25, 2026. Examples and decision criteria are editorial proposals; adapt them to your application's contract and validate them in an authorized test environment.

From design to decision

Compare cloud options

Review pricing, limits, conditions and sources for each option (in Spanish).

Open comparison