Getting a project running on your machine and then packaging it in a container are two of the most common tasks in modern development and DevOps work. This guide takes a Node.js web application (wonderkid) from a fresh git clone to a running Docker container, with an explanation of why each step matters.
Prerequisites: Git, Node.js (v22 recommended) with npm, VS Code, and Docker Desktop.
Part 1: Local Application Setup
Before containerizing anything, confirm the application works on your own machine. If it doesn't run locally, it won't run in a container either, and debugging is much easier outside Docker. Lets follow the following steps to confirm this:
Step 1: Clone the repository
Open VS Code and create a dedicated workspace folder for your projects (in this case, I used the date I created the folder to name the folder ‘oct326’ ). Keeping projects in one place makes them easier to find and manage. Open the integrated terminal (Terminal → New Terminal, or Ctrl + `), navigate to that folder, and clone the repository. To do this you will need the repository url which you will get from the GitHub repository. :
git clone
git clone https://github.com/your-org/wonderkids.git
This downloads the full project, including its history, into a new folder named after the repository.
Step 2: Navigate into the project
Before navigating into the folder, type the Ls command to see that the folder exists
Then use the ‘cd’ command to navigate inside the project folder.
cd
Every command from here on must run from the project root, the folder containing package.json. Running ls (macOS/Linux) or dir (Windows) should show package.json, src/ or app/, and similar files. If you don't see package.json, you're in the wrong folder.
Step 3: Install dependencies
npm install
npm reads package.json (and package-lock.json, if present) and downloads everything the project needs into a node_modules/ folder. This can take a minute or two. Some warnings about deprecated packages are normal, but red ERR! lines mean something failed, often a Node version mismatch, so check the project's README for the required version.
Step 4: Run the app locally
npm run dev
This starts the development server, which usually prints something like:Local: http://localhost:3000
Open that address in your browser to confirm the application loads. Dev servers typically include hot reloading, so changes to your code appear instantly.
When you're finished, stop the server by pressing
Ctrl + C in the terminal.
Tip: If the port is already in use, another process is occupying it. Stop that process or run the app on a different port, depending on your framework's configuration.
Part 2: Docker Application Containerization
Containerization packages your application together with its runtime and dependencies, so it behaves the same on your laptop, a teammate's machine, a CI pipeline, or a cloud host. These are the steps I took to contanerize wonderkid application
Step 1: Check that Docker Desktop is running
Docker commands need the Docker engine running in the background. Launch Docker Desktop and wait until its status shows as running. Then verify from the terminal:
docker --version
docker info
If docker info returns an error like "Cannot connect to the Docker daemon," Docker Desktop isn't running yet.
Step 2: Create the configuration files
From the project root, create the two files Docker needs:
touch Dockerfile
touch .dockerignore
Alternatively, you can create it manually without using the codes.
On Windows PowerShell, use New-Item Dockerfile and New-Item .dockerignore instead, or create the files from VS Code's Explorer panel.
Dockerfile is the recipe that tells Docker how to build your image.
.dockerignore lists files that should not be sent to Docker during the build.
Step 3: Configure the .dockerignore
Without a .dockerignore, Docker copies everything in your folder into the build context, including a large node_modules directory and your Git history. That slows builds and can leak sensitive files. Add:
node_modules
npm-debug.log
.next
dist
build
.git
.gitignore
.env
.env.*
Dockerfile
.dockerignore
README.md
.vscode
Excluding node_modules is especially important. The container installs its own dependencies for its own operating system, and copying yours in can cause conflicts.
Step 4: Write the Dockerfile with a multi-stage build
A multi-stage build uses several FROM stages in one Dockerfile. Build tools and development dependencies are needed to compile your app but not to run it. By building in one stage and copying only the results into a clean final stage, you get a smaller, faster, and more secure image.
Here is a solid starting template using Node 22 on Alpine Linux, a minimal base image:
Stage 1: Install dependencies ----------
FROM node:22-alpine AS deps
WORKDIR /app
Copy only dependency manifests first to leverage Docker layer caching
COPY package.json package-lock.json ./
RUN npm ci
Stage 2: Build the application ----------
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
Stage 3: Production runtime ----------
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
Run as a non-root user for better security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
Copy only what is needed to run the app
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/package-lock.json ./package-lock.json
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
Remove dev-only packages from the final image
RUN npm prune --omit=dev && chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["npm", "start"]
Why it's built this way:
Choice
Reason
node:22-alpine
A current, actively supported Node version on a small base image with a reduced attack surface. Prefer a maintained LTS release.
Three stages
Separates dependency install, build, and runtime so the final image carries only what it needs.
Copying package*.json first
Docker caches layers. If your dependencies haven't changed, rebuilds skip the slow install step.
npm ci
Installs exact versions from the lockfile, giving reproducible builds (faster and stricter than npm install).
Non-root user
If the app is ever compromised, the attacker doesn't get root inside the container.
EXPOSE 3000
Documents which port the app listens on. Use 3001 if your app runs there. (just as we did in this case)
Adapt it to your project.
The template above assumes your build outputs to dist/ and npm start runs the production server. Check your package.json scripts and adjust:
Next.js: the build output is .next/, so copy .next, public, and package.json instead of dist. Next.js's output: "standalone" option can shrink the image further.
Vite/React single-page apps: these produce static files in dist/. A common pattern is a final stage using nginx:alpine that serves the static files, in which case you expose port 80 instead.
Express/NestJS APIs: copy your compiled output (often dist/) and set CMD ["node", "dist/main.js"] or similar.
Also make sure your app listens on 0.0.0.0 (not just localhost) and respects the PORT environment variable. Otherwise it won't be reachable from outside the container.
Step 5: Build the Docker image
From the project root, run:
docker build -t : .
For example I used:
docker build -t wonderkids:latest .
-t assigns a name and tag to the image.
The trailing . tells Docker to use the current directory as the build context.
Docker works through each stage and prints progress. When it finishes, confirm the image exists:
docker images
You'll see your image listed with its size. Compare it to a single-stage build and the multi-stage savings will be clear.
Tip: Use meaningful tags such as wonderkids:1.0.0 rather than relying on latest alone, so you can roll back to a known version.
Step 6: Run the container
Start a container from your image, mapping a port on your machine to the port inside the container:
docker run -p : --name :
For example:
docker run -p 3001:3001 --name wonderkids-app wonderkids:latest
-p 3001:3001 maps host port 3001 to container port 3001. The format is always host:container. To access the app on a different host port, change only the left side: -p 8080:3000 makes it available at http://localhost:8080.
--name gives the container a friendly name instead of a random one.
Open http://localhost:3000 in your browser to confirm the containerized app is running. Press Ctrl + C to stop it if it's running in the foreground.
Useful variations:
Run in the background (detached mode)
docker run -d -p 3000:3000 --name wonderkids-app wonderkids:latest
Automatically remove the container when it stops
docker run --rm -p 3000:3000 wonderkids:latest
The container has been created on Dockerhub.
Everyday Docker Commands Useful for Every DevOps Engineer
Task
Command
List running containers
docker ps
List all containers
docker ps -a
View logs
docker logs wonderkids-app
Follow logs live
docker logs -f wonderkids-app
Stop a container
docker stop wonderkids-app
Start a stopped container
docker start wonderkids-app
Remove a container
docker rm wonderkids-app
Remove an image
docker rmi wonderkids:latest
Open a shell inside a container
docker exec -it wonderkids-app sh
Troubleshooting
"Cannot connect to the Docker daemon." Docker Desktop isn't running. Start it and wait for it to finish initializing.
"Port is already allocated." Something else is using that host port, possibly your local dev server from Part 1 or an older container. Stop it, or map to a different host port (-p 3001:3000).
"Container name already in use." A previous container with that name exists. Remove it with docker rm <container-name> or choose a new name.
Container starts then exits immediately. Check docker logs <container-name>. Common causes are a wrong CMD, a missing build output folder, or missing environment variables.
App runs in the container but the browser can't reach it. Confirm the app binds to 0.0.0.0 rather than 127.0.0.1, and that your -p mapping matches the port the app actually listens on.
Build fails at COPY --from=builder /app/dist. Your framework outputs somewhere other than dist/. Check your build script and update the path.
Conclusion
We've now taken an application through a complete workflow: cloned it, installed dependencies, verified it locally, and then packaged it as an optimized, secure Docker image that runs identically anywhere Docker does. From here, natural next steps are pushing the image to a registry (Docker Hub or Azure Container Registry), using Docker Compose for multi-service setups, and automating image builds in a CI/CD pipeline. We will complete this in the next article.










Top comments (1)
Well written!, thanks
Some comments may only be visible to logged-in visitors. Sign in to view all comments.