This guide walks you through setting up an efficient local API development environment using Docker, Node.js, TypeScript, and Express. It focuses on separating the build process for a smaller final image while maintaining hot-reloading for development.
-
Create project folder Create a new folder for your project in a sensible location, for example:
mkdir -p ~/Documents/daemon-labs/docker-api
You can either create this via a terminal window or your file explorer.
-
Open the new folder in your code editor
If you are using VSCode, we can now do everything from within the code editor.
-
Create
Dockerfile
Add the following content:FROM node:22-alpine WORKDIR /app
-
Create
docker-compose.yaml
Add the following content to define your service:--- services: app: build: .
-
Initial image check
-
Run the following command
docker compose build
If you now run
docker images
, you'll see a newly created image which should be around 226MB in size. -
Run the following command
docker compose run --rm app node --version
The output should start with
v22
followed by the latest minor and patch version.
-
-
Initialise project and install dev dependencies
We use explicit volume mounts (-v ./app:/app
) in the following commands to ensure the generated files are saved back to your local host folder.-
Run the following command
docker compose run --rm -v ./app:/app app npm init -y
Notice how the
app
directory is automatically created on your host machine due to the volume mount. -
Run the following command
docker compose run --rm -v ./app:/app app npm add --save-dev @types/node@22 @tsconfig/recommended typescript
Notice this automatically creates a
package-lock.json
file. Even though dependencies have been installed, if you rundocker images
again, you'll see the image size hasn't changed because thenode_modules
were written to your local volume, not the image layer.
-
-
Create
tsconfig.json
Add the following content to configure the TypeScript compiler:{ "extends": "@tsconfig/recommended/tsconfig.json", "compilerOptions": { "outDir": "./dist" } }
ℹ️ While you could auto-generate this file, our manual configuration using a recommended preset keeps the file minimal and clean.
-
Create source file and scripts
-
Create
./src/index.ts
with the following:console.log("Hello world!");
-
Add the following to the
scripts
section in yourpackage.json
:"start": "node ./dist/index.js", "build": "tsc",
-
-
Update
Dockerfile
Update the end of yourDockerfile
to handle dependencies, build the project, and define the runtime command:COPY ./app/package*.json ./ RUN npm ci COPY ./app . RUN npm run build CMD [ "npm", "start" ]
-
Run final build
-
Run the following command
docker compose build
-
Run the following command
docker compose run --rm app ls -la
Notice that we haven't included
-v ./app:/app
in this command, but the output still includes adist
folder. This is because the build step was executed inside the image during thedocker compose build
process.
-
-
Test the application
-
Run the following command
docker compose up
You should see a couple of lines of Node debug followed by your
Hello world!
.
-
-
Install production dependencies
-
Run the following command
docker compose run --rm -v ./app:/app app npm add express
This dependency is added to the
dependencies
section in your localpackage.json
. -
Run the following command
docker compose run --rm -v ./app:/app app npm add --save-dev @types/express
-
-
Update application code
Update the./src/index.ts
to the following Express server:import express from "express"; const app = express(); const port = 3000; app.get("/", (_req, res) => res.json({ message: "Hello World!" })); app.listen(port, () => console.log(`Example app listening on port ${port}`));
-
Rebuild and test
-
Run the following command
docker compose build
-
Run the following command
docker compose up
⚠️ The container is running but the port is not exposed.
Exit your container by pressingCtrl+C
on your keyboard. -
-
Publish port
-
Update
docker-compose.yaml
to include the port mapping:ports: - 3000:3000
-
Run the following command
docker compose up
If you open
http://localhost:3000
in your browser now, you should see{"message":"Hello World!"}
.
-
-
Prepare for live development
-
Update the
./src/index.ts
fromHello World!
toHello Universe!
You'll notice this change is not reflected upon browser refresh, as the image still contains the old compiled code.
-
Update
docker-compose.yaml
to include the local volume mount for live syncing:volumes: - ./app:/app
-
-
Install development tools
-
Run the following command
docker compose run --rm app npm add --save-dev nodemon ts-node
Note how we no longer need the
-v ./app:/app
argument because the volume mount is now defined in thedocker-compose.yaml
file.
-
-
Configure hot reloading
-
Add a new script in
package.json
calleddev
using the robust command:"dev": "nodemon --exec ts-node ./src/index.ts --legacy-watch",
-
Update
docker-compose.yaml
to override the defaultCMD
with the new development command:command: - npm - run - dev
-
-
Final test
-
Run the following command
docker compose up
Your browser should now return the
Hello Universe!
message. -
Update the
./src/index.ts
back toHello World!
You'll notice in your container logs that
nodemon
has restarted due to changes, and your browser updates without requiring a manual stop/build/start cycle.
-
-
Run a baseline build
-
Run the following command:
docker compose build
If you run
docker images
now, your image contains all dev dependencies and is about 334MB in size. We want to reduce this to only include what is needed for production.
-
-
Create
.dockerignore
This prevents unnecessary files from being copied into the build context.app/dist app/node_modules
We exclude auto-generated and local environment files to ensure clean, repeatable builds.
-
Update the
Dockerfile
for multi-stage build Replace the entire content of yourDockerfile
with the following:FROM node:22-alpine AS base WORKDIR /app FROM base AS build COPY ./app/package*.json ./ RUN npm ci COPY ./app . RUN npm run build FROM base COPY --from=build /app/package*.json ./ COPY --from=build /app/dist ./dist RUN npm ci --only=production CMD [ "npm", "start" ]
-
Create
docker-compose.base.yaml
This file defines the production-ready service configuration.--- services: app: build: . ports: - 3000:3000
This gives us a base production setup.
-
Update
docker-compose.yaml
This file now extends the base and adds the development-specific overrides (volumes and thedev
command).--- services: app: extends: file: docker-compose.base.yaml service: app command: - npm - run - dev volumes: - .:/app
-
Run final build and check size
-
Run the command:
docker compose build
If you run
docker images
now, your final image should be significantly smaller (closer to 230MB), as it only contains the production dependencies and the compiled code. -
Test the production build:
docker compose -f ./docker-compose.base.yaml up
This runs the application in production mode, using the
CMD [ "npm", "start" ]
from the newDockerfile
. -
Test the development setup:
docker compose up
This should still work exactly the same as it did before we made these changes.
-