Skip to main content
CodeOath
← All posts

Docker105 min total · 19 parts

Docker Fundamentals: Images, Containers, and Writing a Good Dockerfile

Part 6 of 19 · ~4 min

Dockerfile Instructions, One by One

Here's the worker image — the piece that pulls jobs off a queue and turns an uploaded receipt into a parsed expense row — with every common instruction represented, and a comment on each one naming what it's for rather than restating what it does:

FROM node:20                     # the base image every later layer builds on
WORKDIR /app                     # working directory for everything below (creates it if missing)
COPY package*.json ./            # bring in just the manifest, for caching (see previous chapter)
ADD receipt-spec-v3.tar.gz /app/spec/   # a local archive, auto-extracted — see COPY vs. ADD below
RUN npm ci                       # install AT BUILD TIME; the result is baked into a layer
ENV NODE_ENV=production          # available at build time AND to the running container
ARG APP_VERSION=dev              # build-time only — never present in the running container
EXPOSE 8080                      # documentation only — does not publish anything (see Networking)
USER node                        # the user this and every later instruction runs as
VOLUME /app/scratch               # declares a mount point intended for transient per-job data
CMD ["node", "worker.js"]        # the default command run when the container starts

COPY vs. ADD: everything COPY can do, ADD can also do, plus two things COPY flatly refuses to: unpack a .tar file sitting locally straight into the destination, and reach out to a URL and fetch whatever's there. The receipt-spec-v3.tar.gz line above is the one case where that first extra ability actually earns its keep — a versioned schema bundle checked into the repo, unpacked automatically as part of the build. Its second ability is a trap disguised as a convenience: point ADD at a URL instead of a local file and the build now depends on some external server being up and returning the same bytes every time, with none of the caching benefit a plain RUN curl step would have given you, and a failure mode that's miserable to reproduce later. Default to COPY for everything; reach for ADD only for the local-archive case, and never for its networking trick.

WORKDIR matters more than it looks — every relative path in every instruction after it, and every relative path the running process itself uses, resolves against whatever directory it last set. Get it wrong partway through a Dockerfile and a COPY can silently land files somewhere the app never looks, with no error at build time at all. VOLUME /app/scratch is a smaller, easy-to-skim instruction with a real purpose here: it's where worker unpacks a receipt while it's rendering pages out of it, and declaring it as a volume mount point signals, right in the Dockerfile, that this directory's contents are meant to be transient working space rather than anything the image itself should ship pre-populated with. Exactly what kind of mount actually gets attached there — a real volume, or something that never touches disk at all — is a decision made at docker run time, and it's worth remembering this path; it comes back later.

ENV vs. ARG: ARG exists only for the duration of the build — passed in with docker build --build-arg, and gone the instant the image is finished. It's invisible in the running container and in docker inspect. ENV is the opposite: once it's baked in, every container born from that image carries it, forever. worker uses both together, because the /version debug endpoint needs to report what actually got deployed:

ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}

Marcus finds out the hard way why secrets don't belong in ARG. Trying to let the worker call Stripe's API in a local integration test, he reaches for docker build --build-arg STRIPE_KEY=sk_test_..., and Priya catches it in review before it ships. Typing a value after --build-arg feels private, the same way typing a password into a terminal feels private — but docker history on the finished image prints it right back out, and so does anything that inspects the build cache, because neither of those was ever designed to treat a build argument as a secret in the first place. For the cases where a build step really does need to read something sensitive, Docker's --secret flag, paired with RUN --mount=type=secret, is the actual answer — the value is available only inside that one RUN, and it never becomes part of any layer. Remember this moment; it comes back later.