How do you make one Docker image run unchanged under `docker run`, Compose in CI, and a scheduler in production?
answer
- One artefact, three runtimes
- Would this value differ between environments?
- Exec form, non-root, stdout, no state
- Promote a digest, not a rebuild
- Enforce against the built image in CI
basics
~20 sDefine a contract the image must satisfy — exec-form entrypoint, non-root user, configuration from the environment with working defaults, logs to stdout, no local state, clean SIGTERM handling — and forbid anything environment-specific in a layer. Promote one digest everywhere; let only the runtime spec differ.
solid answer
~50 sTreat portability as a written contract, not a habit. The image must: use an exec-form `ENTRYPOINT` so the process is PID 1 and gets `SIGTERM`; declare a non-root `USER`; take every environment-specific value from configuration with defaults that work on a laptop; listen on `0.0.0.0`; write logs to stdout and stderr; hold no state on local disk; ship a self-contained health command; and build for every architecture the fleet runs. What must **never** enter a layer is anything that differs between environments — hostnames, ports, credentials, feature flags for "prod". Then promote a single **digest** through the environments rather than rebuilding per stage, so the artefact you tested is the artefact you run. Enforce it in CI against the built image — user, entrypoint form, labels, size, absence of secrets — because a wiki page does not hold. The cost is that variability moves into runtime specs someone must own.
code
dockerfile · 15 lines# syntax=docker/dockerfile:1
FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/checkout ./cmd/checkout
FROM scratch
COPY --from=build /out/checkout /checkout
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
LABEL org.opencontainers.image.source="https://example.com/checkout"
USER 10001:10001
ENV CHECKOUT_ADDR=0.0.0.0:8143
EXPOSE 8143
HEALTHCHECK --interval=20s --timeout=2s CMD ["/checkout", "healthcheck"]
ENTRYPOINT ["/checkout"]go deeper
Know the basics of the contract: an entrypoint in exec form, configuration read from environment variables, logs to stdout, and no credentials or hostnames written into the Dockerfile.
Explain why each rule exists — why shell form loses SIGTERM, why a file log is invisible to the platform, why a baked hostname makes an image single-environment. Be able to point at the line in a Dockerfile that breaks portability.
Show how you would migrate an existing service: what you change in the image first, how you verify parity between a local run and the production spec, and how you move from per-environment builds to promoting one digest.
Own the policy and its costs. Be ready to say where the contract stops, who owns the runtime specs once variability moves there, how you enforce it in CI across many teams, and what you would accept as a justified exception.
## The failure this prevents Teams discover this rule the hard way. An order-checkout service is developed with `docker run`, tested with a Compose file in CI, and deployed by a scheduler in production, and slowly three different images appear: one with the dev database baked in, one with test fixtures, one that branches on `ENV=prod` inside a 200-line entrypoint script. From then on the thing CI verified is not the thing production runs, and every incident starts with "which build is that?". The alternative is to state a contract for what an image is allowed to be, and to make the runtime spec carry every difference. ## The contract **One process, exec form.** `ENTRYPOINT ["/checkout"]`, not a shell string. Shell form runs your process as a child of `/bin/sh -c`, which does not forward `SIGTERM`, so the process never drains and is eventually killed. Rolling replacement sends that signal routinely, so this is not an edge case — it is the normal path. **A non-root `USER` declared in the image.** Every caller inherits it, so running as root becomes an explicit opt-in rather than the default nobody noticed. **Configuration from the environment, with defaults that work.** Bind `0.0.0.0` on a port taken from configuration; default it so a bare `docker run` starts. Absolutely no hostnames, ports, credentials or environment names in a layer. **Logs to stdout and stderr, unbuffered.** Never to a file inside the container: on one host the engine's log driver collects the stream, and every platform expects the same. **No local state.** Anything written to the container filesystem is scratch. Aim for a read-only root filesystem with an explicit writable temp path, which forces the question early. **A health command in the image, thresholds outside it.** Ship a `healthcheck` subcommand or a cheap endpoint so every runtime asks the same question; leave intervals and failure thresholds to whatever runs it. **Multi-architecture.** If any part of the fleet is `arm64`, a single-arch image is a promise you cannot keep, and the failure surfaces only at placement time. ## What must stay outside the image Restart behaviour, port publishing and addressing, resource limits, volumes, service discovery, probe schedules, replica counts, and secret material. All of them differ per environment or per host, which is the test: **if the value could differ between staging and production, it is runtime configuration.** ## Promote a digest, not a tag The contract only pays off if the same artefact moves through the environments. Build once, push once, and promote by immutable digest (`registry.example.com/checkout@sha256:...`) rather than rebuilding per stage or moving a `latest`-style tag. Rebuilding per environment reintroduces the drift you just eliminated, because two builds of the same commit are not guaranteed to produce the same layers. A human-readable tag can point at the digest for convenience; the deployment record should carry the digest. ## The tradeoffs you own **Parameterisation has a ceiling.** Pushing everything into environment variables can produce an image with forty knobs whose valid combinations nobody knows. When a setting is genuinely a property of the artefact — the language runtime, a compiled feature — leave it in the image and build a second image if two variants are truly needed. The rule is about environment-shaped values, not about refusing all opinions. **Variability has to live somewhere.** You have moved it into runtime specs, and those need an owner, review and a schema. Teams that skip this end up with per-environment specs that drift exactly as the per-environment images did. **Local parity is not free.** Making a laptop run the same image as production means sane defaults and often a small Compose file that supplies the dependencies the scheduler supplies in production. That is a real maintenance cost, and it is cheaper than the class of bug it removes. **Size is a deployment property.** A 1.7 GB image is not merely untidy: it is minutes of pull time on every new node and every scale-out, and it widens the surface an image scanner has to report on. Shrinking the order-checkout API to a static binary in a `scratch` base makes replacement fast enough that rolling deployment stops being an event. ## Enforcement Write the contract down and check it in CI **against the built image**, not against the Dockerfile: assert a non-root user, an exec-form entrypoint, required labels such as the source commit, an image size budget, and the absence of well-known credential paths in any layer. A gate that fails the build is the only version of this policy that survives a deadline.
- A team wants a separate image per environment because it is simpler. What is your argument?That the artefact you tested is then not the artefact you shipped, so every environment is its own untested build and a rollback restores a different image than you think. Two builds of one commit are not guaranteed to be identical. Promote one digest and let the runtime spec differ; if two environments genuinely need different code, that is a code branch, not a build flag.
- How do you enforce this contract without relying on review?Check the built image in CI, not the Dockerfile: inspect the config for a non-root user and an exec-form entrypoint, require labels carrying the source commit, apply a size budget, and scan layers for credential-shaped files. Fail the build. A policy that only exists in a document is followed until the first deadline, and the drift is invisible afterwards.
- Where does this contract stop — is it always wrong to bake a setting in?No. The rule targets values that differ per environment. Things that are genuinely properties of the artefact — the runtime version, compiled features, the CA bundle — belong in the image, and pushing them into environment variables produces an image with dozens of knobs whose valid combinations nobody has tested. If two variants are truly required, build two images and name them honestly.
A shipping container is identical everywhere; the crane, the lashings and the berth change at every port. Welding the destination onto the box is what teams do when they build one image per environment.
saying these in an interview costs you the question
- Builds a separate image per environment
- Branches on an ENV=prod variable in the entrypoint
- Uses shell-form ENTRYPOINT so SIGTERM never arrives
- Promotes by moving a mutable tag rather than a digest
- Writes application logs to a file inside the container
- Treats the contract as documentation instead of a CI gate