Containerising an application and building our own images

Docker - Overview and how to use it

6 min read

Published Jul 13 2025


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 image
FROM node:18

# Set working directory inside the container
WORKDIR /app

# Copy package files and install dependencies
COPY package*.json ./
RUN npm install

# Copy the rest of the app
COPY . .

# Expose the port the app runs on
EXPOSE 3000

# Define the command to run the app
CMD ["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 image
WORKDIR /app # Layer 2 - sets working directory
COPY package.json . # Layer 3 - copies package.json
RUN npm install # Layer 4 - installs dependencies
COPY . . # Layer 5 - copies app source
CMD ["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_modules
Dockerfile
.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: Build
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:18-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
RUN npm install --only=production
CMD ["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_value
RUN 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.0
ENV 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 needed
FROM 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-compat
WORKDIR /app

ENV COREPACK_DEFAULT_TO_LATEST=0

# Install dependencies based on the preferred package manager
COPY 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 needed
FROM base AS builder
WORKDIR /app

ARG DATABASE_URI
ARG PAYLOAD_SECRET
ENV DATABASE_URI=$DATABASE_URI
ENV PAYLOAD_SECRET=$PAYLOAD_SECRET

COPY --from=deps /app/node_modules ./node_modules
COPY . .


# 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 1
ENV COREPACK_DEFAULT_TO_LATEST=0

RUN \
  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 next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD 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.

Products from our shop

Docker Cheat Sheet - Print at Home Designs

Docker Cheat Sheet - Print at Home Designs

Docker Cheat Sheet Mouse Mat

Docker Cheat Sheet Mouse Mat

Docker Cheat Sheet Travel Mug

Docker Cheat Sheet Travel Mug

Docker Cheat Sheet Mug

Docker Cheat Sheet Mug

Vim Cheat Sheet - Print at Home Designs

Vim Cheat Sheet - Print at Home Designs

Vim Cheat Sheet Mouse Mat

Vim Cheat Sheet Mouse Mat

Vim Cheat Sheet Travel Mug

Vim Cheat Sheet Travel Mug

Vim Cheat Sheet Mug

Vim Cheat Sheet Mug

SimpleSteps.guide branded Travel Mug

SimpleSteps.guide branded Travel Mug

Developer Excuse Javascript - Travel Mug

Developer Excuse Javascript - Travel Mug

Developer Excuse Javascript Embroidered T-Shirt - Dark

Developer Excuse Javascript Embroidered T-Shirt - Dark

Developer Excuse Javascript Embroidered T-Shirt - Light

Developer Excuse Javascript Embroidered T-Shirt - Light

Developer Excuse Javascript Mug - White

Developer Excuse Javascript Mug - White

Developer Excuse Javascript Mug - Black

Developer Excuse Javascript Mug - Black

SimpleSteps.guide branded stainless steel water bottle

SimpleSteps.guide branded stainless steel water bottle

Developer Excuse Javascript Hoodie - Light

Developer Excuse Javascript Hoodie - Light

Developer Excuse Javascript Hoodie - Dark

Developer Excuse Javascript Hoodie - Dark

© 2025 SimpleSteps.guide
AboutFAQPoliciesContact