Skip to main content

Hands-On: Express and MongoDB with Compose

What We Are Building

A two-service stack you'll recognize from real MERN projects: an Express API talking to MongoDB, defined in a single docker-compose.yml. Data survives restarts thanks to a named volume, and secrets stay in a .env file.

Step 1: Create the Express App

mkdir compose-demo && cd compose-demo
npm init -y
npm install express mongoose

Create index.js:

const express = require('express');
const mongoose = require('mongoose');

const app = express();
app.use(express.json());

const PORT = process.env.PORT || 3000;
const MONGO_URL = process.env.MONGO_URL;

const Note = mongoose.model('Note', { text: String });

app.get('/notes', async (req, res) => {
res.json(await Note.find());
});

app.post('/notes', async (req, res) => {
const note = await Note.create({ text: req.body.text });
res.status(201).json(note);
});

mongoose.connect(MONGO_URL).then(() => {
app.listen(PORT, () => console.log(`API on port ${PORT}`));
});

Note that MONGO_URL comes entirely from the environment — the app has no idea whether Mongo runs locally, in a container, or in Atlas. That's what makes it portable.

Step 2: Add a Dockerfile for the API

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

And a .dockerignore:

node_modules
.env
.git

Step 3: Write the Compose File

Create docker-compose.yml in the project root:

services:
api:
build: .
ports:
- "3000:3000"
environment:
PORT: 3000
MONGO_URL: mongodb://root:${MONGO_PASSWORD}@mongo:27017/notesdb?authSource=admin
depends_on:
- mongo
restart: unless-stopped

mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
volumes:
- mongo-data:/data/db
restart: unless-stopped

volumes:
mongo-data:

Three things to notice:

  • The connection string uses mongo as the hostname — the service name resolves on Compose's internal network, no IP addresses needed.
  • Mongo has no ports mapping. The API reaches it over the internal network; there's no reason to expose the database to your host (or, on a server, to the internet).
  • mongo-data is mounted at /data/db, the directory where MongoDB stores its files.

Step 4: Create the env File

Compose automatically loads a file named .env from the project directory and substitutes ${...} placeholders in the YAML:

# .env
MONGO_PASSWORD=supersecret123

Add it to .gitignore immediately:

echo ".env" >> .gitignore

Verify the substitution without starting anything:

docker compose config

This prints the fully resolved file — a great way to catch a typo in a variable name before it becomes a runtime error.

Step 5: Start the Stack

docker compose up -d --build
  • --build forces the API image to rebuild (use it whenever the Dockerfile or source changed)
  • -d runs everything in the background

Check that both services are healthy:

docker compose ps

Both should show running. If api is restarting, its logs will tell you why:

docker compose logs -f api

Step 6: Test the API

curl -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"text": "compose works"}'

curl http://localhost:3000/notes
# [{"_id":"...","text":"compose works","__v":0}]

Step 7: Prove the Volume Persists Data

This is the test I recommend everyone run once, so volumes stop being abstract:

docker compose down # stops and REMOVES both containers
docker compose up -d
curl http://localhost:3000/notes

Your note is still there. The containers were destroyed and recreated, but /data/db lives in the mongo-data volume, which down does not touch.

Now the destructive version:

docker compose down -v
docker compose up -d
curl http://localhost:3000/notes
# []

-v deletes the named volumes — and with them your data. Reserve it for local resets; never run it casually on a server.

Step 8: Day-to-Day Workflow

docker compose logs -f # stream logs from all services
docker compose logs --tail 50 api # last 50 lines of one service
docker compose restart api # bounce one service
docker compose exec mongo mongosh -u root -p supersecret123 # open a Mongo shell
docker compose down # end of session

After changing API code, the cycle is:

docker compose up -d --build api

Only the api service rebuilds and restarts; Mongo keeps running untouched.

Recap

  1. One YAML file replaced a Dockerfile build, a network, a volume, and two run commands
  2. Services talk over the internal network by name (mongo), and the database stays unexposed
  3. Secrets live in .env, substituted via ${MONGO_PASSWORD}, and never get committed
  4. The named volume survived down and was only wiped by an explicit down -v