Contents

Dockerfile Tutorial: Package Your App into an Image

A Dockerfile is a plain text file with step-by-step instructions for building an image. Docker runs these steps to create an image, which you can then start with docker run, just like nginx.

Sample project

Create a directory named hello-node and add two files.

package.json:

{
  "name": "hello-node",
  "version": "1.0.0",
  "scripts": { "start": "node server.js" }
}

server.js:

const http = require("http");

const server = http.createServer((req, res) => {
  res.end("Hello from Docker!\n");
});

server.listen(3000, () => console.log("Listening on port 3000"));

You don't need to install Node.js on your computer. Node.js is inside the image—that's the point of Docker.

Write a Dockerfile

Create a file named Dockerfile (with no extension) in the same directory:

# 1. Start with the official Node.js image
FROM node:24-alpine

# 2. Set the working directory inside the container
WORKDIR /app

# 3. Copy the package manifests first and install dependencies
COPY package*.json ./
RUN npm install --omit=dev

# 4. Then copy the rest of the code
COPY . .

# 5. Document the port the container uses
EXPOSE 3000

# 6. The command to run when the container starts
CMD ["npm", "start"]

The directory now contains three files:

$ ls
Dockerfile
package.json
server.js

Build an image: docker build

docker build -t hello-node .

-t hello-node is the image name. The final . means to use the current directory as the build context. Docker sends the files in this directory to the build engine, and the COPY instruction can copy only files in the build context.

The terminal shows live progress during the build. When it finishes, the output looks like this:

$ docker build -t hello-node .

[+] Building 9.9s (10/10) FINISHED                                                   docker:default
 => [internal] load build definition from Dockerfile                                           0.0s
 => => transferring dockerfile: 399B                                                           0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                              1.9s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 2B                                                                0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  3.6s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => => sha256:7ce7dcbafb7f750cf0c6be3c30e34372b06dd1182fa67f6ae30782c8150c5444 0B / 444B       7.9s
 => => sha256:02f40d53b06f6b91fec0cd6a6719656da99270bff8ee54ed8dcdf4c157763fc 1.26MB / 1.26MB  0.7s
 => => sha256:3c504d5a797be28f9de43147d49cd8ecb2eea46dfaf7beb8715db695d5f0a 56.52MB / 56.52MB  1.3s
 => => sha256:e2de96513ba9eb53b431787ec8a65cdde380ac4772a3e4c4b714dcfde2a102b 3.85MB / 3.85MB  0.6s
 => => extracting sha256:e2de96513ba9eb53b431787ec8a65cdde380ac4772a3e4c4b714dcfde2a102b5      0.6s
 => => extracting sha256:3c504d5a797be28f9de43147d49cd8ecb2eea46dfaf7beb8715db695d5f0a7f0      2.2s
 => => extracting sha256:02f40d53b06f6b91fec0cd6a6719656da99270bff8ee54ed8dcdf4c157763fc1      0.1s
 => => extracting sha256:7ce7dcbafb7f750cf0c6be3c30e34372b06dd1182fa67f6ae30782c8150c5444      0.0s
 => [internal] load build context                                                              0.0s
 => => transferring context: 759B                                                              0.0s
 => [2/5] WORKDIR /app                                                                         0.0s
 => [3/5] COPY package*.json ./                                                                0.0s
 => [4/5] RUN npm install --omit=dev                                                           1.4s
 => [5/5] COPY . .                                                                             0.0s
 => exporting to image                                                                         2.7s
 => => exporting layers                                                                        0.2s
 => => exporting manifest sha256:b311d231396fab904d11e3fbac8bade8302e8e7401fe14ba1e4a736f4fed  0.0s
 => => exporting config sha256:fa1ad1088d08bf3f3f035dfe9dbed3cf5a008492316926ba23fae3e58d6852  0.0s
 => => exporting attestation manifest sha256:0ea092cede0047cad91ad77043f79ef84c7cab9153913acf  0.0s
 => => exporting manifest list sha256:62664c2b6134a7c365eacb28019730c69b7d1d0fd855c559f6994c3  0.0s
 => => naming to docker.io/library/hello-node:latest                                           0.0s
 => => unpacking to docker.io/library/hello-node:latest                                        2.5s

Each => line is a step:

StepCorresponding Dockerfile instructionDescription
load build definition—Read the Dockerfile
load metadata for …node:24-alpineFROMCheck for the latest version of the base image
[1/5] FROM …FROM node:24-alpineDownload the base image (the sha256:… lines below show each layer's download progress; the first build takes longer)
load build context—Send the build context (only 759B this time, because it contains just 3 small files)
[2/5] WORKDIR /appWORKDIRCreate and switch to /app
[3/5] COPY package*.json ./COPYCopy the package manifests
[4/5] RUN npm install --omit=devRUNInstall packages in the image
[5/5] COPY . .COPYCopy the rest of the code
exporting to image—Save the result as an image and name it hello-node:latest

EXPOSE and CMD are configuration only; they don't create build steps.

Run it

$ docker images hello-node
IMAGE               ID             DISK USAGE   CONTENT SIZE   EXTRA
hello-node:latest   62664c2b6134        245MB         62.2MB

$ docker run -d -p 3000:3000 --name hello hello-node
2c4745bd0187d9ba7f0e9af096519ce9a2ad4436395490f5af24cd142f3bef4c

$ curl -s http://localhost:3000
Hello from Docker!

$ docker logs hello

> hello-node@1.0.0 start
> node server.js

Listening on port 3000
Browser displays Hello from Docker!
Open http://localhost:3000 in a browser
  • The image is about 245 MB, most of which is Node.js itself (node:24-alpine).
  • In docker logs, > node server.js is output from npm start; the app's own Listening on port 3000 comes next.

Rebuild after changing code: cache

Change the text in server.js to Hello from Docker! (v2), then build again:

$ docker build -t hello-node .

[+] Building 0.4s (10/10) FINISHED                                                   docker:default
 => [internal] load build definition from Dockerfile                                           0.0s
 => => transferring dockerfile: 399B                                                           0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                              0.3s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 2B                                                                0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => [internal] load build context                                                              0.0s
 => => transferring context: 294B                                                              0.0s
 => CACHED [2/5] WORKDIR /app                                                                  0.0s
 => CACHED [3/5] COPY package*.json ./                                                         0.0s
 => CACHED [4/5] RUN npm install --omit=dev                                                    0.0s
 => [5/5] COPY . .                                                                             0.0s
 => exporting to image                                                                         0.1s
 => => exporting layers                                                                        0.0s
 => => exporting manifest sha256:667a883ab027b5db474215519a69eef0b162988f9f6384d75c7e6e819ebc  0.0s
 => => exporting config sha256:c3795d6af309587aa9f021692bf628df45d1d38a5e12d05388447644423a01  0.0s
 => => exporting attestation manifest sha256:4598cd1b9ff7979933f7bc06c48df78bb906278618ad587a  0.0s
 => => exporting manifest list sha256:fb09b954543fd2fa9a8d93763672d6a0b8d3ac26e7751e3b3f69b79  0.0s
 => => naming to docker.io/library/hello-node:latest                                           0.0s
 => => unpacking to docker.io/library/hello-node:latest                                        0.0s

This build took only 0.4 seconds:

  • [2/5], [3/5], and [4/5] are marked CACHED: these steps are identical to the previous build, so Docker reuses their results without running npm install again.
  • Only [5/5] COPY . . ran again because server.js changed.
$ docker run --rm -d -p 3000:3000 --name hello hello-node
9d4e4fc490b7b2e5d4c4c5ba729359c63271cbfa7934f6fff011a4fd4d6e87ca

$ curl -s http://localhost:3000
Hello from Docker! (v2)

$ docker stop hello
hello

Why copy package.json first

Each Dockerfile instruction creates a layer, and Docker caches these layers. When an instruction (or a file it copies) changes, every step after that instruction must run again.

If you start with COPY . ., changing any line of code invalidates the cache for npm install, so all packages must be installed again. Copying only package*.json first and then installing means packages need to be reinstalled only when the package manifests change. The more packages your project has, the bigger the difference. See Build cache and optimization for a detailed comparison.

.dockerignore: keep unnecessary files out of the build

During local development, directories often contain lots of files such as node_modules, .git, and log files. To demonstrate, put a 30 MB node_modules directory in the project and build again. Watch the load build context line:

$ docker build -t hello-node .

[+] Building 2.0s (10/10) FINISHED                                                   docker:default
 => [internal] load build definition from Dockerfile                                           0.0s
 => => transferring dockerfile: 399B                                                           0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                              0.2s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 2B                                                                0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => [internal] load build context                                                              0.4s
 => => transferring context: 30.01MB                                                           0.3s
 => CACHED [2/5] WORKDIR /app                                                                  0.0s
 => CACHED [3/5] COPY package*.json ./                                                         0.0s
 => CACHED [4/5] RUN npm install --omit=dev                                                    0.0s
 => [5/5] COPY . .                                                                             0.1s
 => exporting to image                                                                         1.3s
 => => exporting layers                                                                        1.0s
 => => exporting manifest sha256:5c28f64104e51f64822405ae7a67a9016f62e9c5a4c8425b5b92c47791af  0.0s
 => => exporting config sha256:21b97fa4b8887f00cdf80b0dbeb6658f8dd678b8c4008f8d1dcb5c191e82ba  0.0s
 => => exporting attestation manifest sha256:bc9366f473fd5bb6fdffec42cdcd8155bd12b28cfc6ac23d  0.0s
 => => exporting manifest list sha256:f36d091892aca26420e44f681f5279c82f8881c32eb6d77978d99ee  0.0s
 => => naming to docker.io/library/hello-node:latest                                           0.0s
 => => unpacking to docker.io/library/hello-node:latest                                        0.2s

transferring context: 30.01MB: the entire node_modules directory was sent to the build engine, and COPY . . also copies it into the image, overwriting the version installed by npm install inside the image.

Create a .dockerignore file and list the files you don't want sent to the build:

node_modules
.git
.env
*.log

Build again:

$ docker build -t hello-node .

[+] Building 0.4s (10/10) FINISHED                                                   docker:default
 => [internal] load build definition from Dockerfile                                           0.0s
 => => transferring dockerfile: 399B                                                           0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                              0.3s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 69B                                                               0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => [internal] load build context                                                              0.0s
 => => transferring context: 159B                                                              0.0s
 => CACHED [2/5] WORKDIR /app                                                                  0.0s
 => CACHED [3/5] COPY package*.json ./                                                         0.0s
 => CACHED [4/5] RUN npm install --omit=dev                                                    0.0s
 => [5/5] COPY . .                                                                             0.0s
 => exporting to image                                                                         0.1s
 => => exporting layers                                                                        0.0s
 => => exporting manifest sha256:c7dd5ef36303b2a808ee6b594946ee3c029769363a3e7eecfbc53f1c3795  0.0s
 => => exporting config sha256:b23dccadd37348cdcc5e2ca60934e15f7f7f309f477651ab90b6f3759e2e25  0.0s
 => => exporting attestation manifest sha256:217b64bdf5112df5d8d6131ebb08c0fbdf3b30db091103a9  0.0s
 => => exporting manifest list sha256:4063457524bbb10ef5c91def983dc1385e7aac61245c61fc8a4d568  0.0s
 => => naming to docker.io/library/hello-node:latest                                           0.0s
 => => unpacking to docker.io/library/hello-node:latest                                        0.0s

The build context is now 159B. .dockerignore helps by:

  1. Making builds faster, especially in projects with large node_modules or .git directories.
  2. Preventing changes to these files from invalidating the cache for COPY . ..
  3. Keeping secret files such as .env and private keys out of the image.

The host's node_modules may contain native modules compiled for Windows or macOS. Copying them into a Linux container can cause errors. Always exclude node_modules in .dockerignore for Node.js projects.

Stop the container

$ time docker stop hello
hello
real	0m 0.69s
user	0m 0.01s
sys	0m 0.00s

It stopped in 0.7 seconds. The container's main process is npm. When it receives the SIGTERM sent by docker stop, it forwards the signal to Node, which exits normally. If you run Node directly as the main process, you need to handle SIGTERM in your app. Otherwise, docker stop waits the full 10 seconds. See the hands-on test in Dockerfile instructions reference.

Tips

  • Choose an alpine or slim base image to reduce image size significantly (see the size comparison in Images).
  • Pin the base image version, such as node:24-alpine; don't just write node.
  • Use JSON array syntax for CMD (such as ["npm", "start"]); its behavior is clearest.

The next section explains every Dockerfile instruction in detail.

Recap

  • A Dockerfile starts with FROM, and each instruction creates a layer.
  • Build with docker build -t name .; the final . is the build context.
  • Copy package manifests before installing packages so code changes alone can use the cache.
  • Use .dockerignore to exclude unnecessary files such as node_modules, .git, and .env.

Exercises

Exercise 1: Change the response text in server.js and rebuild. Which steps are marked CACHED?

Answer

FROM, WORKDIR, COPY package*.json, and RUN npm install are all CACHED. Only the steps after COPY . . run again because only the code changed.

Exercise 2: Move COPY package*.json ./ and RUN npm install in the Dockerfile after COPY . .. Then change server.js and rebuild. What changes?

Answer

RUN npm install runs again too, making the build slower. Because the contents of COPY . . changed, every subsequent layer must be rebuilt. See Build cache.

FAQ

Does the file have to be named Dockerfile?

By default, yes. Specify a different name with docker build -f filename .; see the [docker build](/docker/docker-build/) reference for the options.

Do I need to install Node.js on my computer?

No. Node.js is in the image, and both the build and app run in containers.

Can a tool generate a Dockerfile for me?

Docker Desktop includes docker init. Answer a few questions to generate a Dockerfile and compose.yaml; see the [docker init](/docker/docker-init/) walkthrough.

Updated October 14, 2026