How to dockerize an app (your first Dockerfile)

To dockerize an app, write a Dockerfile with a base image, your dependencies, your code and a start command. Then run docker build to create the image and docker run to start a container from it.

Updated 5 min read

Learning objectives

After reading this article you will be able to:

  • Write a working Dockerfile for a Node.js app
  • Build the image and run the container with environment variables
  • Shrink the image with .dockerignore, layer caching and multi-stage builds
On this page

"It works on my machine" is usually caused by small differences: the Node version, a system library, a forgotten variable. Dockerizing an app fixes this by packaging everything it needs into an image that behaves the same on your laptop, in CI and on a server.

The starting point is a text file called Dockerfile. The example uses Node.js, but the logic applies to any language: pick a base, install dependencies, copy the code, define the start command.

DockerfileCodedockerbuildImagecodedependenciesbase imageread-onlylayersdockerrunContaineron a laptopContainerin CIContaineron a server
The build packs the Dockerfile and code into a layered image; run starts that same image on any machine.

What do you need before you start?#

  • Docker installed (Docker Desktop on Windows and macOS, or Docker Engine on Linux). Check with docker --version.
  • An app with a known start command. In the example, a package.json and a package-lock.json, with the entry point at src/index.js.
  • A terminal open in the project root.

What is an image and what is a container?#

The image is the package: a base system, dependencies and your code, stored as read-only layers. The container is a running instance of that image. You can start many containers from one image. The Dockerfile is the recipe that describes how to build the image. To see how containers compare with full servers, read VPS vs cloud platform.

How do you write your first Dockerfile?#

Create a file named exactly Dockerfile (no extension) in the project root:

dockerfile
FROM node:lts-alpine
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "src/index.js"]

What each instruction does:

  • FROM node:lts-alpine: the base image. Docker Hub publishes the official node image with version tags, an lts tag (which follows the newest LTS) and alpine (smaller) and slim variants. In production, swap lts for the major version you tested so builds do not change under you.
  • WORKDIR /app: the working directory inside the image. Later commands run there.
  • COPY package.json package-lock.json ./: copies only the dependency files first.
  • RUN npm ci --omit=dev: installs production dependencies exactly as locked.
  • COPY . .: copies the rest of the code.
  • ENV and EXPOSE: set a variable and document the port the app listens on. EXPOSE does not publish the port; that happens in docker run.
  • USER node: the official Node image already ships a node user. Running without root limits the damage if the app is compromised.
  • CMD: the command that runs when the container starts. Use the list (exec) form so no shell is involved.

Why copy package.json before everything else? Each instruction creates a layer, and Docker reuses cached layers that did not change. If you only edited a .js file, npm ci does not run again.

How do you skip files with .dockerignore?#

COPY . . copies everything in the folder, including things that should not be in the image. Create a .dockerignore next to the Dockerfile:

node_modules
.git
.env
**/*.log
Dockerfile
.dockerignore

This keeps out your local node_modules (which may hold incompatible binaries), the Git history and, above all, the secrets in .env. The build gets faster and the image smaller.

How do you build and run the container?#

Build the image and give it a name (tag):

bash
docker build -t my-app .

The trailing dot is the build context: the folder sent to Docker. Then run it:

bash
docker run --rm -p 3000:3000 --env-file .env my-app
  • -p 3000:3000 maps port 3000 on your computer to port 3000 in the container (host:container).
  • --env-file .env supplies variables at run time without baking them into the image. More in environment variables.
  • --rm removes the container when it stops.

Add -d to run in the background, docker ps to list containers and docker logs <id> to see the output.

How do you know it worked?#

  1. docker ps shows the container with status Up.
  2. curl http://localhost:3000 (or the browser) answers like the app running outside Docker.
  3. docker logs <id> shows your program output with no errors.
  4. docker images lists the image and its size.

How do you shrink the image with a multi-stage build?#

If the project needs a build step (TypeScript, bundlers), the build tools do not have to ship in the final image. A multi-stage build uses several FROM lines and copies only the result:

dockerfile
FROM node:lts-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:lts-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]

The first stage installs everything and compiles. The second starts from a clean base, installs only production dependencies and receives the dist folder through COPY --from=build. The compiler and devDependencies stay behind. The example assumes npm run build writes a dist folder with the entry point at dist/index.js; adjust the paths to your project.

What are the common errors?#

The container starts and stops immediately. The CMD process exited. Run docker logs <id> and look for the cause, usually a code error or a missing variable.

The app runs in the container but the browser cannot reach it. You forgot -p, or the app listens on 127.0.0.1. Inside a container it must listen on 0.0.0.0; in Node, app.listen(3000, "0.0.0.0").

Cannot find module in the container. Dependencies were not installed in the image, or .dockerignore excluded a needed file. Review the COPY and RUN npm ci lines.

Slow build on every change. You copied the code before installing dependencies. Keep the order: dependency files, install, then code.

Native binaries break on Alpine. Alpine uses musl instead of glibc, and some packages with native code do not work on it. Switch the base to node:lts-slim.

Secrets in the image. Never use COPY .env or ENV TOKEN=... with real values. Anyone with the image can read them by inspecting the layers.

With the image ready, you still have to decide where it runs and how new versions get there. The full flow is in what deployment is. When the app needs more than one service (a database, a cache), the next step is describing everything in a Docker Compose file.

FAQs

Do I need to know Linux to write a Dockerfile?

The basics help, because RUN commands execute inside a Linux system. For most apps, five instructions (FROM, WORKDIR, COPY, RUN and CMD) and the same commands you already use to install and start the project are enough.

What is the difference between CMD and RUN?

RUN executes during the build and its result is stored in the image (for example, installing dependencies). CMD sets the command that runs when the container starts.

Can I put my .env file inside the image?

It is not recommended. Anyone with the image can read the secrets. Exclude .env with a .dockerignore and pass the variables when you run the container, using --env-file or -e.

Why does my container start and stop right away?

A container lives only as long as its main process (the one from CMD) is running. If that process exits or crashes, the container stops. Check why with docker logs.

Next articleVPS vs shared hostingShared hosting splits one server among many sites and is simple. A VPS gives you root access and reserved resources, but you run it. Here is how to choose.

More in Deploy

See all 33 articles