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.
Before beginning this workshop, please ensure your environment is correctly set up by following the instructions in our prerequisites documentation:
β‘οΈ Prerequisites guide
Run the following command:
docker pull node:22-alpineTip
Pulling the Docker image isn't a requirement, but it helps to have it pre-downloaded so we don't wait for everyone to do it at the same time.
Create a new folder for your project in a sensible location, for example:
mkdir -p ~/Documents/daemon-labs/docker-apiNote
You can either create this via a terminal window or your file explorer.
Tip
If you are using VSCode, we can now do everything from within the code editor.
Add the following content:
FROM node:22-alpine
WORKDIR /appAdd the following content to define your service:
---
services:
app:
build: .Run the following command:
docker compose buildNote
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 --versionNote
The output should start with v22 followed by the latest minor and patch version.
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 -yNote
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 typescriptNote
Notice this automatically creates a package-lock.json file.
Even though dependencies have been installed, if you run docker images again, you'll see the image size hasn't changed because the node_modules were written to your local volume, not the image layer.
Add the following content to configure the TypeScript compiler:
{
"extends": "@tsconfig/recommended/tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
}
}Note
While you could auto-generate this file, our manual configuration using a recommended preset keeps the file minimal and clean.
Create ./app/src/index.ts with the following:
console.log("Hello world!");Add the following to the scripts section in your package.json:
"start": "node ./dist/index.js",
"build": "tsc"Update the end of your Dockerfile 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 the following command:
docker compose buildRun the following command:
docker compose run --rm app ls -laNote
Notice that we haven't included -v ./app:/app in this command, but the output still includes a dist folder. This is because the build step was executed inside the image during the docker compose build process.
Run the following command:
docker compose upNote
You should see a couple of lines of Node debug followed by your Hello world!.
Run the following command:
docker compose run --rm -v ./app:/app app npm add expressNote
This dependency is added to the dependencies section in your local package.json.
Run the following command:
docker compose run --rm -v ./app:/app app npm add --save-dev @types/expressUpdate the ./app/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}`));Run the following command:
docker compose buildRun the following command:
docker compose upWarning
The container is running but the port is not exposed.
Exit your container by pressing Ctrl+C on your keyboard.
Update docker-compose.yaml to include the port mapping:
ports:
- 3000:3000Run the following command:
docker compose upNote
If you open http://localhost:3000 in your browser now, you should see {"message":"Hello World!"}.
Update the ./app/src/index.ts from Hello World! to Hello Universe!
Note
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:/appRun the following command:
docker compose run --rm app npm add --save-dev nodemon ts-nodeNote
Note how we no longer need the -v ./app:/app argument because the volume mount is now defined in the docker-compose.yaml file.
Add a new script in package.json called dev using the robust command:
"dev": "nodemon --exec ts-node ./src/index.ts --legacy-watch"Update docker-compose.yaml to override the default CMD with the new development command:
command:
- npm
- run
- devRun the following command:
docker compose upNote
Your browser should now return the Hello Universe! message.
Update the ./app/src/index.ts back to Hello World!
Note
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 the following command:
docker compose buildNote
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.
This prevents unnecessary files from being copied into the build context.
app/dist
app/node_modules
Note
We exclude auto-generated and local environment files to ensure clean, repeatable builds.
Replace the entire content of your Dockerfile 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" ]This file defines the production-ready service configuration.
---
services:
app:
build: .
ports:
- 3000:3000Note
This gives us a base production setup.
This file now extends the base and adds the development-specific overrides (volumes and the dev command).
---
services:
app:
extends:
file: docker-compose.base.yaml
service: app
command:
- npm
- run
- dev
volumes:
- ./app:/appRun the command:
docker compose buildNote
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 upNote
This runs the application in production mode, using the CMD [ "npm", "start" ] from the new Dockerfile.
Test the development setup:
docker compose upNote
This should still work exactly the same as it did before we made these changes.
You've just learnt to build and develop a basic API project with Docker.