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


12
0
0
0

CLIContainersDevOpsDockerImagesNetworksVolumes

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 command

Each 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.git

This 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 AS for clarity.
  • Use COPY --from=<stage> to move only what's needed.
  • Use minimal base images for the final stage (like alpine, slim, or distroless).
  • 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.0 is a versioned tag.
  • my-app:latest is a convenience tag, usually pointing to the current stable build.

After building, you can confirm it exists with:

docker images

You 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.
  • ARG is only available in the stage where it’s defined - On mulit-stage builds, they can be used to set ENV values needed for building, that you don't want appearing in the final image. If you want to use the same ARG in 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=$VERSION

This lets you inject values during build and keep them available at runtime if needed.


Security Consideration

  • ARG values are not included in the final image unless used in ENV, but they can still be seen in image history.
  • ENV values are visible with docker 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 \  . --push

Explanation:

  • --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:latest

Common list of platforms:

OS

Architecture

Platform string

Notes

Linux

amd64

linux/amd64

Most common for servers and desktops (Intel/AMD 64-bit)

Linux

arm64

linux/arm64

Common for Apple M1/M2, Raspberry Pi 4+

Linux

arm/v7

linux/arm/v7

32-bit ARM (Raspberry Pi 2/3, older SBCs)

Linux

arm/v6

linux/arm/v6

Very old ARM devices

Linux

386

linux/386

32-bit x86 (legacy PCs)

Linux

s390x

linux/s390x

IBM mainframe systems

Linux

ppc64le

linux/ppc64le

IBM PowerPC Little Endian

Windows

amd64

windows/amd64

Windows containers (requires Windows host or LCOW support)

Windows

arm64

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 ls

To see a full platform list:

docker buildx inspect --bootstrap



Example 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.js

Build 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.
© 2025 SimpleSteps.guide
AboutFAQPoliciesContact
Docker - Overview and how to use it | Containerising an application and building our own images | SimpleSteps.guide