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
mongoas the hostname — the service name resolves on Compose's internal network, no IP addresses needed. - Mongo has no
portsmapping. 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-datais 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
--buildforces the API image to rebuild (use it whenever the Dockerfile or source changed)-druns 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
- One YAML file replaced a Dockerfile build, a network, a volume, and two run commands
- Services talk over the internal network by name (
mongo), and the database stays unexposed - Secrets live in
.env, substituted via${MONGO_PASSWORD}, and never get committed - The named volume survived
downand was only wiped by an explicitdown -v