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:
| Step | Corresponding Dockerfile instruction | Description |
|---|---|---|
load build definition | — | Read the Dockerfile |
load metadata for …node:24-alpine | FROM | Check for the latest version of the base image |
[1/5] FROM … | FROM node:24-alpine | Download 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 /app | WORKDIR | Create and switch to /app |
[3/5] COPY package*.json ./ | COPY | Copy the package manifests |
[4/5] RUN npm install --omit=dev | RUN | Install packages in the image |
[5/5] COPY . . | COPY | Copy 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

- The image is about 245 MB, most of which is Node.js itself (
node:24-alpine). - In
docker logs,> node server.jsis output fromnpm start; the app's ownListening on port 3000comes 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 runningnpm installagain.- Only
[5/5] COPY . .ran again becauseserver.jschanged.
$ 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:
- Making builds faster, especially in projects with large
node_modulesor.gitdirectories. - Preventing changes to these files from invalidating the cache for
COPY . .. - Keeping secret files such as
.envand private keys out of the image.
The host's
node_modulesmay contain native modules compiled for Windows or macOS. Copying them into a Linux container can cause errors. Always excludenode_modulesin.dockerignorefor 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
alpineorslimbase 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 writenode. - 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
.dockerignoreto exclude unnecessary files such asnode_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