Containerising an application and building our own images
Docker - Overview and how to use it
6 min read
Published Jul 13 2025, updated Aug 17 2026
Guide Sections
Guide Comments
Containerisation is the process of packaging an application along with all its dependencies, libraries, and configuration files into a single unit — an image — which can run reliably as a container in any environment.
Understand Your App's Structure
Before writing a Dockerfile, it's essential to understand your app:
- What language and framework does it use? (e.g., Node.js, Python, Go)
- What are its runtime dependencies?
- How is it started? (e.g.,
npm start,python app.py) - Are there configuration files (e.g.,
.env,config.json)?
Example Node.js project structure:
my-app/├── package.json├── package-lock.json├── server.js└── public/Create a Dockerfile
The Dockerfile is a script that tells Docker how to build your image. Here's an example for a simple Node.js app:
# Use an official base imageFROM node:18# Set working directory inside the containerWORKDIR /app# Copy package files and install dependenciesCOPY package*.json ./RUN npm install# Copy the rest of the appCOPY . .# Expose the port the app runs onEXPOSE 3000# Define the command to run the appCMD ["npm", "start"]Explanation:
FROM: Specifies the base image (e.g.,node,python,ubuntu).WORKDIR: Sets the directory where all commands will be run.COPY: Adds files from your machine into the image.RUN: Executes commands during image build (e.g., install dependencies).CMD: The default command run when a container starts.EXPOSE: Indicates the port the container will listen on.
Each command in a Dockerfile results in a new image layer:
FROM node:18 # Layer 1 - base imageWORKDIR /app # Layer 2 - sets working directoryCOPY package.json . # Layer 3 - copies package.jsonRUN npm install # Layer 4 - installs dependenciesCOPY . . # Layer 5 - copies app sourceCMD ["node", "index.js"] # Layer 6 - default commandEach layer stores only the changes made compared to the previous one. This has benefits:
- Caching: If nothing has changed in an earlier step, Docker can reuse cached layers.
- Efficiency: Smaller rebuilds and faster development.
- Portability: Layers can be downloaded individually and reused across images.
Docker ignore file
Similar to .gitignore, you should create a .dockerignore file to avoid copying unnecessary files:
node_modulesDockerfile.dockerignore.gitThis speeds up builds and keeps your image clean and secure.
Multi-Stage Builds
Multi-stage builds allow you to create complex Docker images that are small, clean, and production-ready, while still using intermediate steps for building, testing, or compiling.
Key idea:
- Use one or more build stages to compile code or perform setup.
- Then copy the result into a final minimal image, leaving behind the build tools and dependencies.
Example of a multi-stage Dockerfile:
# Stage 1: BuildFROM node:18 AS builderWORKDIR /appCOPY package*.json ./RUN npm installCOPY . .RUN npm run build# Stage 2: ProductionFROM node:18-slimWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/package.json ./RUN npm install --only=productionCMD ["node", "dist/index.js"]Benefits:
- Final image is much smaller (no dev dependencies or build tools).
- Keeps the image cleaner and more secure.
- Multiple build stages can be used — for example:
builder,tester,production,docs.
Best Practices with Multi-Stage Builds:
- Name your stages with
ASfor clarity. - Use
COPY --from=<stage>to move only what's needed. - Use minimal base images for the final stage (like
alpine,slim, ordistroless). - Always cache intelligently: keep frequently-changing steps lower in the Dockerfile.
Build the Docker Image
Once your Dockerfile is ready, you can build the image:
docker build -t my-app .Options:
-t my-app: Tags the image with a name (my-app)..: Tells Docker to look for the Dockerfile in the current directory.
You can add version and latest tags when building too:
docker build -t my-app:1.0.0 -t my-app:latest .Adds tags:
my-app:1.0.0is a versioned tag.my-app:latestis a convenience tag, usually pointing to the current stable build.
After building, you can confirm it exists with:
docker imagesYou can pass build time arguments when building a Dockerfile.
ARG defines build-time variables, which are used only while the image is being built. They do not persist in the final image unless explicitly passed into ENV.
In the Dockerfile:
ARG MY_VAR=default_valueRUN echo "Value is $MY_VAR"You pass values during build with --build-arg:
docker build --build-arg MY_VAR=hello .If not passed, it defaults to the value in the Dockerfile (default_value in this case).
Good For:
- Configuring optional tools or behavior in builds.
- Passing tokens, Git SHA versions, or feature flags.
- Avoiding leaking secrets into final images.
Scope:
- Available only after declared in the Dockerfile.
- Not accessible at runtime unless passed to
ENV. ARGis only available in the stage where it’s defined - On mulit-stage builds, they can be used to setENVvalues needed for building, that you don't want appearing in the final image. If you want to use the sameARGin multiple stages, you must redeclare it in each stage.
You can use an ARG to define a default value and pass it into ENV:
ARG VERSION=1.0.0ENV APP_VERSION=$VERSIONThis lets you inject values during build and keep them available at runtime if needed.
Security Consideration
ARGvalues are not included in the final image unless used inENV, but they can still be seen in image history.ENVvalues are visible withdocker inspect, so avoid storing secrets or passwords there.
Multi-Architecture Builds
By default, Docker builds for the host architecture (e.g., ARM64 on M1 Macs, x86_64 on Intel). To build for multiple platforms, use Docker Buildx, which extends Docker's build capabilities.
docker buildx build \ --platform linux/amd64,linux/arm64 \ -t myusername/my-app:1.0.0 \ -t myusername/my-app:latest \ . --pushExplanation:
--platform: Targets architectures (e.g.,linux/amd64,linux/arm64)-t: Adds one or more tags.: Build context--push: Pushes the multi-arch manifest and actual images to a registry (required to make the multi-arch image useful)
Without --push, Docker can only build for your local architecture unless you're using an emulation backend.
You can check which platforms are supported by an existing image:
docker buildx imagetools inspect myusername/my-app:latestCommon list of platforms:
OS | Architecture | Platform string | Notes |
Linux | amd64 |
| Most common for servers and desktops (Intel/AMD 64-bit) |
Linux | arm64 |
| Common for Apple M1/M2, Raspberry Pi 4+ |
Linux | arm/v7 |
| 32-bit ARM (Raspberry Pi 2/3, older SBCs) |
Linux | arm/v6 |
| Very old ARM devices |
Linux | 386 |
| 32-bit x86 (legacy PCs) |
Linux | s390x |
| IBM mainframe systems |
Linux | ppc64le |
| IBM PowerPC Little Endian |
Windows | amd64 |
| Windows containers (requires Windows host or LCOW support) |
Windows | arm64 |
| ARM based Windows containers, quite rare |
Note: Windows containers require Windows hosts — Linux-based Docker engines (like Docker Desktop on Mac) cannot run Windows containers natively.
To see which platforms your builder supports:
docker buildx lsTo see a full platform list:
docker buildx inspect --bootstrapExample of a more detailed production multi-stage Dockerfile
This is an example NextJS application Dockerfile that produces a small production ready image.
FROM node:22.12.0-alpine AS base# Install dependencies only when neededFROM base AS deps# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.RUN apk add --no-cache libc6-compatWORKDIR /appENV COREPACK_DEFAULT_TO_LATEST=0# Install dependencies based on the preferred package managerCOPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./RUN \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ elif [ -f package-lock.json ]; then npm ci; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ else echo "Lockfile not found." && exit 1; \ fi# Rebuild the source code only when neededFROM base AS builderWORKDIR /appARG DATABASE_URIARG PAYLOAD_SECRETENV DATABASE_URI=$DATABASE_URIENV PAYLOAD_SECRET=$PAYLOAD_SECRETCOPY --from=deps /app/node_modules ./node_modulesCOPY . .# Next.js collects completely anonymous telemetry data about general usage.# Learn more here: https://nextjs.org/telemetry# Uncomment the following line in case you want to disable telemetry during the build.# ENV NEXT_TELEMETRY_DISABLED 1ENV COREPACK_DEFAULT_TO_LATEST=0RUN \ if [ -f yarn.lock ]; then yarn run ci; \ elif [ -f package-lock.json ]; then npm run ci; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run ci; \ else echo "Lockfile not found." && exit 1; \ fi# Production image, copy all the files and run nextFROM base AS runnerWORKDIR /appENV NODE_ENV production# Uncomment the following line in case you want to disable telemetry during runtime.# ENV NEXT_TELEMETRY_DISABLED 1RUN addgroup --system --gid 1001 nodejsRUN adduser --system --uid 1001 nextjs# Set the correct permission for prerender cacheRUN mkdir .nextRUN chown nextjs:nodejs .nextCOPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/staticCOPY --from=builder --chown=nextjs:nodejs /app/public ./publicUSER nextjsEXPOSE 3000ENV PORT 3000CMD HOSTNAME="0.0.0.0" node server.jsBuild steps:
- Installs the node packages required for building the application, based on the preferred package manager.
- Builds the application and runs database migrations, utilising build arguments to know the database connection string but this value isn't included in the final built image.
- Copies over just the final built files on top of a fresh base layer - ie. no files only needed at build time are included in the final production image, reducing the size.