Lab 10: Docker in Production — Environments, Multi-Stage Builds, and Databases
Overview
This lab covers advanced Docker topics: managing multiple deployment environments using separate Docker Compose files, writing multi-stage Dockerfiles to produce lean production images, connecting a Node.js application to a MongoDB database running in a Docker container, persisting database data using Docker volumes, and adding a Mongo Express web UI as an additional service.
Objectives
- Separate development and production configurations using multiple Docker Compose files
- Use a shared
docker-compose.ymlbase file with environment-specific override files - Write a multi-stage Dockerfile with separate
developmentandproductionbuild stages - Run MongoDB as a Docker container alongside a Node.js application
- Use Docker networking so containers communicate by service name
- Persist database data across container restarts using named Docker volumes
- Add Mongo Express as an admin UI container for MongoDB
Prerequisites
- Labs 08 and 09 completed (Docker basics, single-container Node.js app, Docker Compose)
- Docker and Docker Compose installed
- A Node.js application with a
package.jsonalready in place
Background
1. Docker Environments (Dev vs Prod)
Running the same application in development and production requires different settings:
| Setting | Development | Production |
|---|---|---|
| Node startup command | npm run dev (nodemon) | npm start |
NODE_ENV | development | production |
| Volume bind-mount (live reload) | Yes | No |
| Install dev dependencies | Yes | No (--only=production) |
Strategy: One Base File + Per-Environment Override Files
Instead of duplicating a full Docker Compose file for every environment, use:
docker-compose.yml— shared configuration (ports, service name, build context)docker-compose.dev.yml— development-specific overrides (volumes, command, env)docker-compose.prod.yml— production-specific overrides (command, env)
docker-compose.yml (base — shared settings)
version: "3"
services:
node-app:
build: .
ports:
- "3000:3000"docker-compose.dev.yml (development overrides)
version: "3"
services:
node-app:
volumes:
- ./:/app
- /app/node_modules
environment:
- NODE_ENV=development
command: npm run devdocker-compose.prod.yml (production overrides)
version: "3"
services:
node-app:
environment:
- NODE_ENV=production
command: npm startRunning with a specific environment
Specify both files with -f. Docker Compose merges them — the override file wins for any key that appears in both.
# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Rebuild and start (after Dockerfile changes)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
# Stop (same files must be specified)
docker compose -f docker-compose.yml -f docker-compose.dev.yml downNote: You must specify both
-ffiles when runningdownas well. If Docker Compose cannot find the service name it will report an error.
2. Multi-Stage Dockerfile
A multi-stage Dockerfile lets you use different build instructions for different environments — all in a single file. Docker Compose points to the correct stage using the target field.
Why multi-stage?
| Problem | Solution |
|---|---|
| Dev dependencies included in the production image | Only run npm install --only=production in the production stage |
| Wrong startup command per environment | Each stage defines its own CMD |
| Large final image size | Production stage skips dev tools entirely |
Single-file multi-stage Dockerfile
FROM node:14 AS base
WORKDIR /app
COPY package*.json ./
FROM base AS development
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
FROM base AS production
RUN npm install --only=production
COPY . .
CMD ["npm", "start"]Explanation:
basestage — sets the working directory and copiespackage.jsononly; no install yetdevelopmentstage (FROM base AS development) — fullnpm installincluding dev dependencies; starts with nodemonproductionstage (FROM base AS production) —npm install --only=productionskips dev tools; starts normally
Pointing Docker Compose to a stage
Replace the plain build: . with a build block specifying context and target:
docker-compose.dev.yml
version: "3"
services:
node-app:
build:
context: .
target: development
volumes:
- ./:/app
- /app/node_modules
environment:
- NODE_ENV=developmentdocker-compose.prod.yml
version: "3"
services:
node-app:
build:
context: .
target: production
environment:
- NODE_ENV=productionArchitecture diagram
graph TD
A[Dockerfile] --> B["base stage\nFROM node:14\nWORKDIR /app\nCOPY package*.json"]
B --> C["development stage\nRUN npm install\nCMD npm run dev"]
B --> D["production stage\nRUN npm install --only=production\nCMD npm start"]
E["docker-compose.dev.yml\ntarget: development"] --> C
F["docker-compose.prod.yml\ntarget: production"] --> D3. Docker with MongoDB and Node.js
Adding MongoDB as a service
MongoDB has an official image on Docker Hub. Add it as a second service in docker-compose.yml:
version: "3"
services:
node-app:
build: .
ports:
- "3000:3000"
mongo:
image: mongo
environment:
- MONGO_INITDB_ROOT_USERNAME=root
- MONGO_INITDB_ROOT_PASSWORD=example
volumes:
- mongo-db:/data/db
volumes:
mongo-db:The
volumes:section at the bottom declares the named volumemongo-db. The service maps it to/data/dbinside the container — that is where MongoDB stores its data files.
Connecting from Node.js using Mongoose
Install mongoose:
npm install mongooseIn your Node.js application (index.js):
const mongoose = require("mongoose");
const DB_USER = "root";
const DB_PASSWORD = "example";
const DB_HOST = "mongo"; // Docker service name — resolved automatically by Docker DNS
const DB_PORT = 27017;
const URI = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}`;
mongoose
.connect(URI)
.then(() => console.log("Connected to database"))
.catch((err) => console.log("Failed to connect to database", err));Key point: Use the Docker Compose service name (
mongo) as the hostname. Docker's built-in DNS maps the service name to the container's IP address automatically. You never need to hard-code an IP address.
How Docker networking works
When Docker Compose starts multiple services it creates a default network and attaches all containers to it. Each container gets an IP address, and Docker's internal DNS maps every service name to its container's current IP.
graph LR
subgraph default_network["Docker Default Network"]
A["node-app\ncontainer"] <-->|"hostname: mongo"| B["mongo\ncontainer"]
end
C["Host Machine"] -->|":3000"| AInspecting containers and networks
# List running containers
docker ps
# Inspect a container (shows IP, network, env, volumes)
docker inspect <container_name>
# List Docker networks
docker network ls
# Inspect the default network (shows all containers and their IPs)
docker network inspect <network_name>Verifying the connection
# Follow application logs
docker logs <node_app_container> -f
# Expected output:
# App is up and running on port 3000
# Connected to databaseWorking inside the MongoDB container
# Open a terminal on the MongoDB container
docker exec -it <mongo_container> bash
# Start the mongo shell
mongosh -u root -p example
# Inside mongosh
show dbs
use testdb
db.books.insertOne({ title: "Book One" })
db.books.find()You can also run a command directly without opening a shell:
docker exec -it <mongo_container> mongosh -u root -p example --eval "show dbs"4. Docker Volumes for Data Persistence
Without a volume, all MongoDB data is lost when the container stops.
Demonstrating the data-loss problem:
docker compose down # stops and removes containers
docker compose up -d # restarts — database is empty againSolution: Named Volume
Add to docker-compose.yml:
services:
mongo:
image: mongo
environment:
- MONGO_INITDB_ROOT_USERNAME=root
- MONGO_INITDB_ROOT_PASSWORD=example
volumes:
- mongo-db:/data/db # named volume -> MongoDB data directory inside container
volumes:
mongo-db: # declare the named volume hereVolume management commands:
# List all volumes
docker volume ls
# Remove a specific volume
docker volume rm <volume_name>
# Remove all unused volumes (cleanup)
docker volume prune
# Stop containers but KEEP volumes (default)
docker compose down
# Stop containers AND DELETE volumes
docker compose down -vWarning:
docker compose down -vpermanently deletes your database data. Never use-von a production database.
5. Mongo Express Admin UI
Mongo Express is a web-based MongoDB admin interface that runs as its own Docker container alongside MongoDB.
Adding Mongo Express to Docker Compose
version: "3"
services:
node-app:
build: .
ports:
- "3000:3000"
mongo:
image: mongo
environment:
- MONGO_INITDB_ROOT_USERNAME=root
- MONGO_INITDB_ROOT_PASSWORD=example
volumes:
- mongo-db:/data/db
mongo-express:
image: mongo-express
ports:
- "8081:8081"
environment:
- ME_CONFIG_MONGODB_ADMINUSERNAME=root
- ME_CONFIG_MONGODB_ADMINPASSWORD=example
- ME_CONFIG_MONGODB_SERVER=mongo
depends_on:
- mongo
volumes:
mongo-db:Mongo Express environment variables:
| Variable | Value | Purpose |
|---|---|---|
ME_CONFIG_MONGODB_ADMINUSERNAME | root | MongoDB admin username |
ME_CONFIG_MONGODB_ADMINPASSWORD | example | MongoDB admin password |
ME_CONFIG_MONGODB_SERVER | mongo | Service name of the MongoDB container |
depends_on: - mongotells Docker Compose to start themongocontainer before startingmongo-express.
Starting all three services
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --buildOpen http://localhost:8081 in a browser to access the Mongo Express UI.
Full three-container architecture
graph LR
Browser -->|":8081"| ME["mongo-express\ncontainer"]
App["node-app\ncontainer"] -->|"service: mongo"| DB["mongo\ncontainer"]
ME -->|"service: mongo"| DB
DB --- Vol[("mongo-db\nvolume")]
subgraph net["Docker Default Network"]
App
ME
DB
endLab Tasks
Task 1: Create Dev and Prod Configurations
Goal: Split a single docker-compose.yml into a base file plus two environment-specific override files.
Steps:
-
Create a project folder structure:
my-app/ ├── index.js ├── package.json ├── Dockerfile ├── .dockerignore ├── docker-compose.yml ├── docker-compose.dev.yml └── docker-compose.prod.yml -
Write
docker-compose.yml(shared base):version: "3" services: node-app: build: . ports: - "3000:3000" -
Write
docker-compose.dev.yml:version: "3" services: node-app: volumes: - ./:/app - /app/node_modules environment: - NODE_ENV=development command: npm run dev -
Write
docker-compose.prod.yml:version: "3" services: node-app: environment: - NODE_ENV=production command: npm start -
Start in development mode:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build -
Verify the app at
http://localhost:3000. -
Edit
index.jsand confirm live reload (file changes are reflected without rebuilding). -
Stop and switch to production:
docker compose -f docker-compose.yml -f docker-compose.dev.yml down docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build -
Edit
index.jsagain and confirm changes are NOT picked up without a rebuild.
Expected output when starting:
[+] Running 1/1
✔ Container my-app-node-app-1 StartedTask 2: Write a Multi-Stage Dockerfile
Goal: Replace a single-environment Dockerfile with a multi-stage Dockerfile targeting dev and prod stages separately.
Steps:
-
Write a multi-stage
Dockerfile:FROM node:14 AS base WORKDIR /app COPY package*.json ./ FROM base AS development RUN npm install COPY . . CMD ["npm", "run", "dev"] FROM base AS production RUN npm install --only=production COPY . . CMD ["npm", "start"] -
Update
docker-compose.dev.ymlto target the development stage:version: "3" services: node-app: build: context: . target: development -
Update
docker-compose.prod.ymlto target the production stage:version: "3" services: node-app: build: context: . target: production -
Build and run in production:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build -
Verify
nodemonis NOT installed in production:docker exec -it <container_name> bash ls node_modules | grep nodemon # Expected: no output -
Stop, then build and run in development:
docker compose -f docker-compose.yml -f docker-compose.prod.yml down docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build -
Verify
nodemonIS present:docker exec -it <container_name> bash ls node_modules | grep nodemon # Expected: nodemon
Task 3: Connect Node.js App with MongoDB
Goal: Add MongoDB as a service and connect the Node.js app using Mongoose.
Steps:
-
Install Mongoose in your project:
npm install mongoose -
Update
docker-compose.ymlto add the MongoDB service and a named volume:version: "3" services: node-app: build: . ports: - "3000:3000" mongo: image: mongo environment: - MONGO_INITDB_ROOT_USERNAME=root - MONGO_INITDB_ROOT_PASSWORD=example volumes: - mongo-db:/data/db volumes: mongo-db: -
Add a database connection in
index.js:const mongoose = require("mongoose"); const URI = "mongodb://root:example@mongo:27017"; mongoose .connect(URI) .then(() => console.log("Connected to database")) .catch((err) => console.log("Failed to connect:", err)); -
Start the services:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build -
Verify the connection:
docker logs <node_app_container> -fExpected:
App is up and running on port 3000 Connected to database -
Insert a record inside MongoDB:
docker exec -it <mongo_container> mongosh -u root -p example use testdb db.books.insertOne({ title: "Docker Lab 10" }) db.books.find() -
Test data persistence:
docker compose down docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d docker exec -it <mongo_container> mongosh -u root -p example use testdb db.books.find()Expected: The record is still present after restart.
Task 4: Add Mongo Express Admin UI
Goal: Add Mongo Express as a third service and browse the database through a web UI.
Steps:
-
Update
docker-compose.ymlto add Mongo Express:version: "3" services: node-app: build: . ports: - "3000:3000" mongo: image: mongo environment: - MONGO_INITDB_ROOT_USERNAME=root - MONGO_INITDB_ROOT_PASSWORD=example volumes: - mongo-db:/data/db mongo-express: image: mongo-express ports: - "8081:8081" environment: - ME_CONFIG_MONGODB_ADMINUSERNAME=root - ME_CONFIG_MONGODB_ADMINPASSWORD=example - ME_CONFIG_MONGODB_SERVER=mongo depends_on: - mongo volumes: mongo-db: -
Start all services:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build -
Verify three containers are running:
docker psExpected (3 rows):
CONTAINER ID IMAGE STATUS xxxxxxxxxxxx node-app Up xxxxxxxxxxxx mongo Up xxxxxxxxxxxx mongo-express Up -
Open
http://localhost:8081in a browser. -
Navigate to
testdb→booksand confirm the record from Task 3 is visible. -
Use the Mongo Express UI to insert a new document.
-
Verify the new document appears when you run
db.books.find()in the mongo shell.
Summary
| Concept | Key Takeaway |
|---|---|
| Environment separation | Use a shared docker-compose.yml + per-environment override files with -f |
| Override merging | Docker Compose merges files — values in the override file win |
| Multi-stage Dockerfile | One Dockerfile, multiple FROM ... AS <stage> blocks; target: selects the stage |
| Production dependencies | npm install --only=production skips dev tools |
| Container networking | Docker Compose creates a default network; use service names as hostnames |
docker inspect | Shows container IP address, network membership, volumes, and environment |
| Named volumes | Declared in volumes: block; persist data across container restarts |
docker compose down -v | Deletes containers AND volumes — use with caution |
| Mongo Express | Web UI for MongoDB; connects to the mongo container by service name |
depends_on | Controls startup order between services |