How to Containerize an Application With Docker
A step-by-step Dockerfile walkthrough for containerizing an app, with practical tips for smaller, faster and safer images.
To containerize an application, write a Dockerfile that starts from a small official base image, copies in your dependency files, installs dependencies, copies your code and sets the start command. Build it with docker build, run it with docker run, then shrink and secure it with a .dockerignore file, multi-stage builds and a non-root user.
Photo: Unsplash
On this page
“It works on my machine” is the most expensive sentence in software. An app runs fine on a developer’s laptop, then fails in testing or production because of a different language version, a missing library or a config file nobody remembered.
Containerizing your application fixes that. You package the app with everything it needs to run into a single image that behaves the same everywhere: your laptop, a CI server, or a cloud platform. This guide walks through containerizing a typical web app with Docker step by step, then shows how to make the image smaller and safer.
If you are new to containers, read our beginner’s guide to what Docker is and our comparison of containers vs virtual machines first.
What you need before you start
| Requirement | Why |
|---|---|
| Docker installed | Docker Desktop on Mac or Windows, or Docker Engine on Linux |
| An app that runs from the command line | You need one command that starts it, such as npm start or python app.py |
| A dependency file | package.json, requirements.txt, pom.xml or similar |
| Configuration through environment variables | So the same image can run in every environment |
| A port the app listens on | For web apps, so you can reach it from outside the container |
A licensing note for businesses: Docker’s terms say Docker Desktop is free for personal use, education, non-commercial open source and small businesses with fewer than 250 employees and less than $10 million in annual revenue. Larger organizations need a paid subscription. Docker Engine on Linux is separate. Check the current terms for your situation.
Steps 1 and 2: learn the building blocks and get a starter file
Step 1: Understand the 3 building blocks
- Dockerfile: a text file of instructions for building your image
- Image: the packaged, read-only result: your app, its runtime and its dependencies
- Container: a running instance of an image
You write a Dockerfile once, build an image from it, and run as many containers from that image as you need.
Step 2: Generate a starting point (optional)
If you use Docker Desktop, the docker init command can create starter files for you. According to Docker’s documentation, it generates a Dockerfile, .dockerignore, compose.yaml and a README.Docker.md, with templates for ASP.NET Core, Go, Java, Node, PHP with Apache, Python, Rust and a general-purpose option.
It is a good way to start, but you should still understand every line. The rest of this guide builds a Dockerfile by hand so you know what each part does. Note that docker init can overwrite existing files with those names, so back them up first.
Steps 3 to 5: write, build and run your first image
Step 3: Write a basic Dockerfile
Here is a simple Dockerfile for a Node.js web app. The same structure works for most languages.
```dockerfile FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./ RUN npm ci –omit=dev
COPY . .
EXPOSE 3000 CMD [“node”, “server.js”] ```
What each line does:
FROMpicks a base image. Use an official image with a specific version tag, and prefer a slim variant.WORKDIRsets the folder inside the container where commands run.COPY package.json ...copies only the dependency files first. This matters for caching, explained below.RUN npm ciinstalls dependencies exactly as locked, skipping development packages.COPY . .copies the rest of your code.EXPOSEdocuments the port the app listens on.CMDsets the command that starts the app, in the exec form with square brackets so the app receives shutdown signals properly.
Why the order of instructions matters. Docker builds images in layers and caches each one. If a layer’s inputs have not changed, Docker reuses it. By copying dependency files and installing packages before copying the rest of your code, a code-only change reuses the cached dependency layer. That can cut rebuilds from minutes to seconds.
Step 4: Add a .dockerignore file
Without a .dockerignore, COPY . . sends everything in your folder to the build, including local dependency folders, Git history and possibly secrets. Docker’s best practices guide recommends a .dockerignore to exclude anything not needed for the build.
node_modules
.git
.env
*.log
dist
coverage
Dockerfile
.dockerignore
Always exclude .env files and other local secrets. Anything copied into an image can be extracted by anyone who has the image.
Step 5: Build and run the image
Build the image and give it a name and tag:
docker build -t my-app:1.0 .
Run a container from it, mapping port 3000 on your machine to port 3000 in the container and passing configuration as environment variables:
docker run --rm -p 3000:3000 -e DATABASE_URL="your-connection-string" my-app:1.0
Open http://localhost:3000 to test it. Check docker images to see how big the image is. That number is your baseline for the next steps.
If your app needs a database or cache, use Docker Compose to define and start all the services together with one compose.yaml file, rather than running each container by hand.
Key takeaway: A working Dockerfile is only the first draft. The real gains come from 3 changes: a
.dockerignore, a multi-stage build, and running as a non-root user. Together they make images smaller, faster to ship and harder to attack.
Steps 6 and 7: make the image smaller and safer
Step 6: Make the image smaller with a multi-stage build
Many apps need build tools, compilers or development dependencies to build, but not to run. A multi-stage build uses one stage to build the app and a clean final stage that copies in only what is needed at runtime. Docker’s documentation recommends multi-stage builds for exactly this reason.
```dockerfile
Stage 1: build
FROM node:24-slim AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build
Stage 2: production dependencies only
FROM node:24-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci –omit=dev
Stage 3: final runtime image
FROM node:24-slim ENV NODE_ENV=production WORKDIR /app COPY –from=deps /app/node_modules ./node_modules COPY –from=build /app/dist ./dist COPY package.json ./ USER node EXPOSE 3000 CMD [“node”, “dist/server.js”] ```
For compiled languages like Go or Rust, the gain is even bigger: build in a full image, then copy the single compiled binary into a very small final image.
Step 7: Make the image safer
| Practice | Why it helps | How |
|---|---|---|
| Use trusted, minimal base images | Fewer packages mean fewer vulnerabilities | Official or verified publisher images, slim variants |
| Pin versions | Builds are repeatable | Specific tags, or digests for full reproducibility |
| Run as a non-root user | Limits damage if the app is compromised | Add a USER instruction; many official images include one, such as node |
| Never bake in secrets | Build args and environment variables persist in the image | Pass secrets at runtime, or use build secret mounts during builds |
| Install only what you need | Smaller attack surface and image size | No editors, debuggers or extra tools in production images |
| Scan images | Finds known vulnerabilities in packages | Use an image scanner in your CI pipeline |
| Rebuild regularly | Picks up patched base images | Scheduled rebuilds with --pull |
Docker’s build documentation is explicit that build arguments and environment variables are not suitable for secrets because they persist in the final image. If a build step needs a token, for example to install private packages, use a secret mount (RUN --mount=type=secret,...) so it is available only to that step.
Container images are part of your software supply chain. Our explainer on supply chain attacks covers why base images and dependencies deserve the same scrutiny as your own code.
Step 8: Push the image and automate builds
Once the image works locally:
- Tag it with a version or Git commit, not just
latest. - Push it to a registry, such as Docker Hub, GitHub Container Registry, GitLab Container Registry or your cloud provider’s registry.
- Build in CI/CD so every merge produces a tested, scanned image automatically. Our guide to building a CI/CD pipeline shows the structure.
- Deploy to a container platform: a managed container service, a serverless container product, or Kubernetes if you run many services. Our comparison of Docker vs Kubernetes explains when you need an orchestrator.
Common mistakes to avoid
- Using
latestas your only tag. You lose track of what is running and cannot roll back reliably. - Storing data inside the container. Containers are meant to be disposable. Use volumes or managed databases for anything that must survive a restart.
- Putting several services in one container. Docker recommends one concern per container: the app in one, the database in another.
- Ignoring logs. Write logs to standard output and standard error so the platform can collect them.
- Hard-coding configuration. Read settings from environment variables so the same image runs in development, staging and production.
Conclusion
Containerizing an application comes down to a short Dockerfile, a sensible build order and a few security habits. Start with a working basic image, add a .dockerignore, move to a multi-stage build and run as a non-root user. Then let your CI/CD pipeline build, scan and push images automatically. Once that is in place, “it works on my machine” becomes “it works everywhere.”
Sources
- Docker Docs: Building best practices — multi-stage builds, base images, pinning, .dockerignore, non-root users and rebuilding
- Docker Docs: docker init — generated files and supported templates
- Docker Docs: Build secrets — why build args and environment variables are unsuitable for secrets, and secret mounts
- Docker Docs: Docker Desktop license agreement — free-use thresholds for small businesses
Frequently asked questions
What does it mean to containerize an application?
Containerizing an application means packaging it with its runtime, libraries, dependencies and configuration defaults into a container image. That image runs the same way on any machine with a container runtime, from a laptop to a cloud server. It removes "works on my machine" problems and makes deployments repeatable and easier to scale.
Can any application be containerized?
Most applications can, especially web apps, APIs, background workers and command-line tools. Apps that need special hardware, a graphical desktop, or a specific licensed operating system are harder. Legacy apps that write data to local disk or depend on fixed server names often need small changes, such as using environment variables and external storage, before they containerize cleanly.
How long does it take to containerize an app?
A simple web app can be containerized in an hour or two, including writing a Dockerfile, testing it and adding a .dockerignore. Larger or older applications can take days or weeks if they need configuration changes, external storage, or splitting into several services. Production readiness, including CI/CD, scanning and monitoring, usually takes longer than the Dockerfile itself.
Is Docker free for businesses?
Docker Engine and the Docker CLI on Linux are free. Docker Desktop is free for personal use, education, non-commercial open source and small businesses with fewer than 250 employees and under $10 million in annual revenue. Larger organizations and government entities need a paid Docker subscription to use Docker Desktop. Check Docker's current license terms before rolling it out.
How do I make my Docker image smaller?
Use a slim or minimal official base image, add a .dockerignore file, use a multi-stage build so build tools stay out of the final image, install only production dependencies, and clean package manager caches in the same layer that installs packages. For compiled languages, copying a single binary into a tiny final image produces the smallest results.
Should my container run as root?
No, not unless it truly needs root privileges. Docker's best practices recommend switching to a non-root user with the USER instruction. If an attacker compromises an app running as root, they gain more power inside the container and potentially more ways to escape it. Many official images already include a non-root user you can switch to.
Do I need Kubernetes after containerizing my app?
Not necessarily. Many teams run containers on managed container services, serverless container platforms or a single server with Docker Compose. Kubernetes becomes worth its complexity when you run many services that need automated scaling, self-healing and rolling updates across a cluster. Start simple and adopt Kubernetes when you outgrow simpler platforms.
Was this guide useful
No reviews yet. Share your experience and help other readers.