How do I package my NodeJS app for production? A Docker Guide.
Learn how to Dockerize a Node.js Express app for production: write a lean, secure Dockerfile, test the image locally, publish it to Docker Hub, and deploy it on Hostly.
You have written a Node.js application, it works on your machine, and now you want to run it somewhere else. To do that on a container-hosting platform such as Hostly, you first need to package the application as a container image.
How to dockerize a Node.js Application
This article walks through the complete process using a small Express API written in JavaScript. We will create the application, write a practical Dockerfile, test the image locally, publish it to Docker Hub, and finally deploy it on Hostly.
The application itself will remain intentionally simple. When you open it in a browser, it returns a small JSON greeting. This keeps the focus on creating and publishing the image rather than on the application code.
What you will need
Before starting, make sure you have:
- Node.js and npm installed
- Docker Desktop or Docker Engine installed
- A free Docker Hub account (optional)
- A text editor or IDE
The examples use Node.js 24, which is the current LTS release line at the time of writing.
1. Create a small Node.js project
Create a new directory for the application and move into it:
$ mkdir hello-node-api && cd hello-node-apiInitialize a new npm project:
$ npm init -yWe will use Express to create the HTTP server:
$ npm install --save expressThe application will use modern JavaScript modules, so enable ES modules in package.json:
$ npm pkg set type=moduleAdd a start command:
$ npm pkg set scripts.start="node src/main.js"Create a directory for the source code:
$ mkdir srcYour project should now contain roughly the following files:
hello-node-api/
├── node_modules/
├── src/
├── package.json
└── package-lock.jsonThe package-lock.json file is important for the Docker build later on. It records the dependency versions installed by npm and allows us to use npm ci inside the image.
2. Create the Express application
Create a file named src/main.js:
import express from "express";
const app = express();
const port = Number(process.env.PORT) || 3000;
app.get("/", (_request, response) => {
response.json({
message: "Hello from a Dockerized Node.js application!",
});
});
app.listen(port, "0.0.0.0", () => {
console.log(`Server listening on port ${port}`);
});There are two details in this small application that matter when it is run inside a container.
First, the port can be supplied through the PORT environment variable. If no value is provided, the application uses port 3000.
Second, the server listens on 0.0.0.0 rather than localhost. Inside a container, binding only to localhost would make the server unreachable from outside that container. Listening on 0.0.0.0 allows Docker (and later Hostly) to send traffic to it.
You can test the application before creating the image:
$ npm startThen open the following address in your browser:
http://localhost:3000The browser should show a message similar to this:
{
"message": "Hello from a Dockerized Node.js application!"
}Stop the server with Ctrl+C.
3. Add a .dockerignore file
When Docker builds an image, it receives the contents of the project directory as its build context. We do not want to send local dependencies, Git metadata, environment files, or other unnecessary files.
Create a file named .dockerignore in the project root:
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
coverageThe most important entry is node_modules.
Dependencies installed on your computer may have been built for a different operating system or CPU architecture than the container. Instead of copying them, the Docker build should install a clean set of dependencies inside the image.
Environment files are also excluded because they frequently contain credentials or other secrets. Configuration values should be supplied when the container is deployed rather than embedded permanently in the image.
4. Create the Dockerfile
Create a file named Dockerfile in the project root. The filename has no extension.
Add the following content:
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
&& npm cache clean --force
COPY --chown=node:node src ./src
USER node
EXPOSE 3000
CMD ["node", "src/main.js"]The finished project structure should now look like this:
hello-node-api/
├── src/
│ └── main.js
├── .dockerignore
├── Dockerfile
├── package.json
└── package-lock.jsonLet us go through the Dockerfile one section at a time.
Choose the base image
FROM node:24-alpineEvery image starts from a base image. In this case, we use the official Node.js 24 image based on the alpine linux distribution. This is typically the smallest and most light-weight base image available.
Alpine is a good fit for this simple application and produces a smaller image. Some Node.js packages with native components expect theglibcsystem library used by Debian. If you encounter native dependency or binary compatibility problems, usenode:24-bookworm-sliminstead.
Using a specific major Node.js version makes the build more predictable than using a floating tag such as node:latest. Patch releases within Node.js 24 can still be included when the base image is updated.
Set the production environment
ENV NODE_ENV=productionMany Node.js libraries use NODE_ENV to distinguish production from development behaviour.
Select the working directory
WORKDIR /appThis creates or selects /app as the working directory for the remaining Dockerfile instructions and for the running application.
Copy and install the dependencies
COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
&& npm cache clean --forceThe package files are copied before the application source. Docker can reuse this dependency layer when the source code changes but package.json and package-lock.json remain the same.
npm ci installs the versions recorded in the lock file. The --omit=dev option leaves out development-only dependencies because they are not needed to run the production container. Docker’s Node.js guide similarly recommends switching to npm ci once a lock file exists.
Copy the application
COPY --chown=node:node src ./srcThis copies the source code into /app/src.
The --chown option assigns the files to the node user included in the official Node.js image.
Stop running as root
USER nodeContainers run as the root user unless the image or Dockerfile specifies otherwise. Our application does not need root privileges, so we switch to the existing node user before starting it.
Running the application as a non-root user limits what it can modify inside the container if the application or one of its dependencies is compromised. The official Node.js image defines this unprivileged node user for this purpose.
Document the application port
EXPOSE 3000EXPOSE documents the port used by the application. It does not publish the port automatically, nor does it force the application to use that port.
The actual port still comes from the application configuration. When deploying on Hostly, the port entered in the service configuration must match the port on which the application listens.
Start Node.js directly
CMD ["node", "src/main.js"]This is the command that runs when a container is started from the image.
We start Node.js directly instead of using npm start, which avoids placing an additional npm process between the container runtime and the application.
5. Build the image
From the directory containing the Dockerfile, run:
$ docker build -t hello-node-api:1.0.0 .The final period is important. It tells Docker to use the current directory as the build context.
The -t option assigns a name and tag to the image:
hello-node-api:1.0.0Docker executes the instructions in the Dockerfile and creates a container image containing Node.js, the production dependencies, and the application source.
After the build finishes, you can confirm that the image exists:
$ docker image ls6. Run the image locally
Start a container from the image:
$ docker run --rm -p 3000:3000 hello-node-api:1.0.0The -p option maps a port on your computer to a port inside the container. Its format is:
HOST_PORT:CONTAINER_PORTIn this example, traffic sent to port 3000 on your computer is forwarded to port 3000 in the container.
The --rm option removes the stopped container automatically. It does not delete the image.
Test the running container:
$ curl http://localhost:3000You should receive the same JSON response as before in the browser:
{
"message": "Hello from a Dockerized Node.js application!"
}You have now confirmed that the application works independently of the Node.js installation on your computer.
Stop the container with Ctrl+C.
7. Publish the image to Docker Hub
The image currently exists only on your machine. For Hostly to deploy it, the image must be available from a container registry.
We will use a public Docker Hub repository for this tutorial.
Sign in to Docker Hub and create a public repository named:
hello-node-apiThen authenticate from your terminal:
$ docker loginTag the local image with your Docker Hub username:
$ docker tag hello-node-api:1.0.0 YOUR_USERNAME/hello-node-api:1.0.0Replace YOUR_USERNAME with your actual Docker Hub username.
Push the tagged image:
$ docker push YOUR_USERNAME/hello-node-api:1.0.0Docker Hub image names include the account namespace, repository name, and tag. Docker’s documented publishing workflow is to tag the local image with that complete name and then upload it using docker push.
After the upload finishes, the complete image address is:
docker.io/YOUR_USERNAME/hello-node-api:1.0.0Using a version such as 1.0.0 instead of relying only on latest makes it clear which image a deployment is running. When you publish a new version later, you can use a new tag such as 1.0.1.
A note about CPU architectures
A normal docker build creates an image for the architecture used by your Docker environment. For example, an Apple Silicon computer will ordinarily produce an ARM64 image.
When you need one image that supports both common server and desktop architectures, Docker Buildx can publish a multi-platform image:
$ docker buildx build \
--platform linux/amd64,linux/arm64 \
-t YOUR_USERNAME/hello-node-api:1.0.0 \
--push .A multi-platform image contains variants for the specified architectures, allowing the container runtime to select the appropriate one when it pulls the image.
For this pure JavaScript example, creating both variants should normally be straightforward. Applications with native Node.js dependencies may require additional attention during a cross-platform build.
8. Deploy the image on Hostly
The final deployment is deliberately short because all of the application packaging work has already been completed.
Create a new service in Hostly and provide:
- Your desired
hostly.ninjasubdomain - The image URL:
docker.io/YOUR_USERNAME/hello-node-api:1.0.0 - Container port:
3000
This example does not require environment variables, secrets, volumes, or configuration files, so those sections can remain empty.
Click Deploy.
Hostly pulls the image, starts the container, routes traffic to it, and provides SSL for the selected subdomain. Service health is checked by opening a TCP connection to the configured port, so the application does not need a special health-check endpoint.
Once the deployment is ready, opening the assigned hostly.ninja address should return:
{
"message": "Hello from a Dockerized Node.js application!"
}For this tutorial, we used a public Docker Hub repository so that the image can be pulled without authentication. Hostly can also deploy images from private repositories when the appropriate registry credentials are supplied.
What you have built
You started with a few lines of JavaScript and turned them into a portable container image that can be run locally or on a hosting platform.
The important parts were not specific to the Hello World response. The same process applies to a larger Express or Fastify application:
- Make the server listen on a configurable port and
0.0.0.0. - Keep dependencies locked in
package-lock.json. - Exclude local dependencies and secrets using
.dockerignore. - Install only production dependencies in the image.
- Run the application as a non-root user.
- Build and test the image locally.
- Publish it to a container registry.
- Give Hostly the image address and application port.
The application may become more complex, but the basic deployment contract remains small: an image, a port, and the configuration the application needs to run.