Lab 1: Infrastructure as Code

Building a MERN Stack with Docker Compose

Jason Kuruzovich

2026-01-20

Lab 1 Overview

Building Your Development Foundation

Today’s Objectives

Lab Learning Goals

  1. Create a complete MERN development environment from scratch
  2. Write production-ready Dockerfiles for Node.js applications
  3. Configure Docker Compose for multi-service orchestration
  4. Implement hot reloading for efficient development
  5. Understand container networking and service communication
  6. Practice Infrastructure as Code principles

Lab Structure

Time Activity
0:00 - 0:15 Lab overview and requirements
0:15 - 0:45 Guided setup: Project structure and API
0:45 - 1:15 Guided setup: Frontend and MongoDB
1:15 - 1:30 Break
1:30 - 1:45 Docker Compose orchestration
1:45 - 2:00 Testing and troubleshooting

What You’ll Build

┌─────────────────────────────────────────────────────────────────┐
│                    Your MERN Development Stack                   │
│                                                                  │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐         │
│  │   React     │    │   Express   │    │   MongoDB   │         │
│  │  Frontend   │───►│     API     │───►│  Database   │         │
│  │  :3000      │    │   :4000     │    │   :27017    │         │
│  └─────────────┘    └─────────────┘    └─────────────┘         │
│                                                                  │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │              Mongo Express (Admin UI) :8081              │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                  │
│  All running in Docker containers with hot reload               │
└─────────────────────────────────────────────────────────────────┘

Project Setup

Final Directory Structure

lab1-mern-stack/
├── docker-compose.yml           # Orchestration
├── docker-compose.prod.yml      # Production config
├── .env                         # Environment variables
├── .env.example                 # Template
├── .gitignore
├── README.md
│
├── api/                         # Express backend
│   ├── Dockerfile
│   ├── Dockerfile.dev
│   ├── package.json
│   ├── .dockerignore
│   └── src/
│       ├── server.js
│       ├── routes/
│       ├── models/
│       └── middleware/
│
└── frontend/                    # React frontend
    ├── Dockerfile
    ├── Dockerfile.dev
    ├── package.json
    ├── .dockerignore
    └── src/
        ├── App.jsx
        ├── index.jsx
        └── components/

Step 1: Initialize Project

# Create project directory
mkdir lab1-mern-stack
cd lab1-mern-stack

# Initialize git
git init

# Create directory structure
mkdir -p api/src/{routes,models,middleware}
mkdir -p frontend/src/components

# Create essential files
touch docker-compose.yml
touch .env .env.example .gitignore

Step 2: Create .gitignore

# .gitignore
# Dependencies
node_modules/

# Environment files (never commit secrets)
.env
.env.local
.env.*.local

# Build outputs
dist/
build/

# IDE
.idea/
.vscode/
*.swp

# OS
.DS_Store
Thumbs.db

# Logs
*.log
npm-debug.log*

# Docker
.docker/

Step 3: Environment Configuration

# .env.example (commit this)
NODE_ENV=development
API_PORT=4000
FRONTEND_PORT=3000
MONGODB_URI=mongodb://mongo:27017/mernapp
MONGO_EXPRESS_PORT=8081
# .env (don't commit this)
NODE_ENV=development
API_PORT=4000
FRONTEND_PORT=3000
MONGODB_URI=mongodb://mongo:27017/mernapp
MONGO_EXPRESS_PORT=8081

Building the API

API package.json

{
  "name": "mern-api",
  "version": "1.0.0",
  "description": "MERN Stack API",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "test": "jest"
  },
  "dependencies": {
    "express": "^4.18.2",
    "mongoose": "^8.0.0",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "helmet": "^7.1.0"
  },
  "devDependencies": {
    "nodemon": "^3.0.2",
    "jest": "^29.7.0"
  }
}

API Server (server.js)

// api/src/server.js
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const helmet = require('helmet');

const app = express();
const PORT = process.env.PORT || 4000;
const MONGODB_URI = process.env.MONGODB_URI;

// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json());

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  });
});

// API routes
app.get('/api', (req, res) => {
  res.json({ message: 'Welcome to the MERN API!' });
});

// Import routes
const itemRoutes = require('./routes/items');
app.use('/api/items', itemRoutes);

// Error handling middleware
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

// Connect to MongoDB and start server
mongoose.connect(MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB');
    app.listen(PORT, '0.0.0.0', () => {
      console.log(`API server running on port ${PORT}`);
    });
  })
  .catch(err => {
    console.error('MongoDB connection error:', err);
    process.exit(1);
  });

Item Model (models/Item.js)

// api/src/models/Item.js
const mongoose = require('mongoose');

const itemSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Name is required'],
    trim: true,
    maxlength: [100, 'Name cannot exceed 100 characters']
  },
  description: {
    type: String,
    trim: true,
    maxlength: [500, 'Description cannot exceed 500 characters']
  },
  completed: {
    type: Boolean,
    default: false
  }
}, {
  timestamps: true  // Adds createdAt and updatedAt
});

// Index for common queries
itemSchema.index({ completed: 1, createdAt: -1 });

module.exports = mongoose.model('Item', itemSchema);

Items Routes (routes/items.js)

// api/src/routes/items.js
const express = require('express');
const router = express.Router();
const Item = require('../models/Item');

// GET all items
router.get('/', async (req, res) => {
  try {
    const items = await Item.find().sort({ createdAt: -1 });
    res.json(items);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// GET single item
router.get('/:id', async (req, res) => {
  try {
    const item = await Item.findById(req.params.id);
    if (!item) {
      return res.status(404).json({ error: 'Item not found' });
    }
    res.json(item);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// POST new item
router.post('/', async (req, res) => {
  try {
    const item = new Item(req.body);
    await item.save();
    res.status(201).json(item);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// PUT update item
router.put('/:id', async (req, res) => {
  try {
    const item = await Item.findByIdAndUpdate(
      req.params.id,
      req.body,
      { new: true, runValidators: true }
    );
    if (!item) {
      return res.status(404).json({ error: 'Item not found' });
    }
    res.json(item);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// DELETE item
router.delete('/:id', async (req, res) => {
  try {
    const item = await Item.findByIdAndDelete(req.params.id);
    if (!item) {
      return res.status(404).json({ error: 'Item not found' });
    }
    res.json({ message: 'Item deleted successfully' });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

module.exports = router;

API Dockerfiles

Development

# api/Dockerfile.dev
FROM node:20-alpine

WORKDIR /app

# Install dependencies
COPY package*.json ./
RUN npm install

# Don't copy src - use volume mount

EXPOSE 4000

CMD ["npm", "run", "dev"]

Production

# api/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./

USER node
EXPOSE 4000
CMD ["npm", "start"]

API .dockerignore

# api/.dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
*.md
.idea
.vscode
coverage
.nyc_output
*.log

Building the Frontend

Frontend Setup

# From project root
cd frontend

# Create React app with Vite
npm create vite@latest . -- --template react

# Or if you prefer Create React App
npx create-react-app . --template cra-template

Frontend package.json (Vite)

{
  "name": "mern-frontend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite --host",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "axios": "^1.6.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.2.0",
    "vite": "^5.0.0"
  }
}

Vite Configuration

// frontend/vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    host: '0.0.0.0',
    port: 3000,
    watch: {
      usePolling: true  // Required for Docker
    },
    proxy: {
      '/api': {
        target: 'http://api:4000',
        changeOrigin: true
      }
    }
  }
})

App Component (App.jsx)

// frontend/src/App.jsx
import { useState, useEffect } from 'react'
import axios from 'axios'
import './App.css'

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000'

function App() {
  const [items, setItems] = useState([])
  const [newItem, setNewItem] = useState('')
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetchItems()
  }, [])

  const fetchItems = async () => {
    try {
      setLoading(true)
      const response = await axios.get(`${API_URL}/api/items`)
      setItems(response.data)
      setError(null)
    } catch (err) {
      setError('Failed to fetch items')
      console.error(err)
    } finally {
      setLoading(false)
    }
  }

  const addItem = async (e) => {
    e.preventDefault()
    if (!newItem.trim()) return

    try {
      const response = await axios.post(`${API_URL}/api/items`, {
        name: newItem
      })
      setItems([response.data, ...items])
      setNewItem('')
    } catch (err) {
      setError('Failed to add item')
    }
  }

  const toggleItem = async (id, completed) => {
    try {
      const response = await axios.put(`${API_URL}/api/items/${id}`, {
        completed: !completed
      })
      setItems(items.map(item =>
        item._id === id ? response.data : item
      ))
    } catch (err) {
      setError('Failed to update item')
    }
  }

  const deleteItem = async (id) => {
    try {
      await axios.delete(`${API_URL}/api/items/${id}`)
      setItems(items.filter(item => item._id !== id))
    } catch (err) {
      setError('Failed to delete item')
    }
  }

  return (
    <div className="app">
      <h1>MERN Stack Items</h1>

      {error && <div className="error">{error}</div>}

      <form onSubmit={addItem}>
        <input
          type="text"
          value={newItem}
          onChange={(e) => setNewItem(e.target.value)}
          placeholder="Add new item..."
        />
        <button type="submit">Add</button>
      </form>

      {loading ? (
        <p>Loading...</p>
      ) : (
        <ul>
          {items.map(item => (
            <li key={item._id} className={item.completed ? 'completed' : ''}>
              <span onClick={() => toggleItem(item._id, item.completed)}>
                {item.name}
              </span>
              <button onClick={() => deleteItem(item._id)}>Delete</button>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

export default App

Frontend Dockerfiles

Development

# frontend/Dockerfile.dev
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

# Source mounted as volume

EXPOSE 3000

CMD ["npm", "run", "dev"]

Production

# frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Docker Compose Orchestration

Complete docker-compose.yml

services:
  # React Frontend
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.dev
    container_name: mern-frontend
    ports:
      - "${FRONTEND_PORT:-3000}:3000"
    volumes:
      - ./frontend:/app
      - /app/node_modules
    environment:
      - VITE_API_URL=http://localhost:${API_PORT:-4000}
    depends_on:
      - api
    networks:
      - mern-network

  # Express API
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.dev
    container_name: mern-api
    ports:
      - "${API_PORT:-4000}:4000"
    volumes:
      - ./api:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - PORT=4000
      - MONGODB_URI=${MONGODB_URI}
    depends_on:
      mongo:
        condition: service_healthy
    networks:
      - mern-network

  # MongoDB Database
  mongo:
    image: mongo:7
    container_name: mern-mongo
    ports:
      - "27017:27017"
    volumes:
      - mongo-data:/data/db
      - ./mongo-init:/docker-entrypoint-initdb.d
    environment:
      - MONGO_INITDB_DATABASE=mernapp
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    networks:
      - mern-network

  # MongoDB Admin UI
  mongo-express:
    image: mongo-express
    container_name: mern-mongo-express
    ports:
      - "${MONGO_EXPRESS_PORT:-8081}:8081"
    environment:
      - ME_CONFIG_MONGODB_URL=mongodb://mongo:27017/
      - ME_CONFIG_BASICAUTH=false
    depends_on:
      mongo:
        condition: service_healthy
    networks:
      - mern-network

volumes:
  mongo-data:
    name: mern-mongo-data

networks:
  mern-network:
    name: mern-network
    driver: bridge

Service Communication Diagram

flowchart LR
    subgraph Host["Host Machine (Your Computer)"]
        Browser[Web Browser]
    end

    subgraph Docker["Docker Network: mern-network"]
        Frontend["frontend:3000"]
        API["api:4000"]
        Mongo["mongo:27017"]
        MongoExpress["mongo-express:8081"]
    end

    Browser -->|localhost:3000| Frontend
    Browser -->|localhost:4000| API
    Browser -->|localhost:8081| MongoExpress

    Frontend -->|api:4000| API
    API -->|mongo:27017| Mongo
    MongoExpress -->|mongo:27017| Mongo

Starting the Stack

# Build and start all services
docker compose up --build

# Or in detached mode
docker compose up -d --build

# Watch the logs
docker compose logs -f

# Check status
docker compose ps

Expected Output

[+] Running 4/4
 ✔ Container mern-mongo          Healthy
 ✔ Container mern-mongo-express  Started
 ✔ Container mern-api           Started
 ✔ Container mern-frontend      Started

mern-api       | Connected to MongoDB
mern-api       | API server running on port 4000
mern-frontend  | VITE v5.0.0  ready in 500 ms
mern-frontend  |   ➜  Local:   http://localhost:3000/

Testing Your Stack

Verification Checklist

# 1. Check all containers are running
docker compose ps

# 2. Test API health endpoint
curl http://localhost:4000/health

# 3. Test API items endpoint
curl http://localhost:4000/api/items

# 4. Create an item
curl -X POST http://localhost:4000/api/items \
  -H "Content-Type: application/json" \
  -d '{"name": "Test Item"}'

# 5. Open frontend
open http://localhost:3000

# 6. Open Mongo Express
open http://localhost:8081

Test Hot Reload

  1. API Hot Reload:

    # Edit api/src/server.js
    # Change the welcome message
    # Save - watch terminal for nodemon restart
    curl http://localhost:4000/api
  2. Frontend Hot Reload:

    # Edit frontend/src/App.jsx
    # Change the h1 title
    # Save - browser updates automatically

Common Issues & Solutions

Issue Cause Solution
Port already in use Another service using port Change port in .env or stop other service
MongoDB connection refused DB not ready Wait or check depends_on health
Hot reload not working Volume mount issue Ensure paths correct, restart container
CORS errors API not allowing frontend Check CORS configuration
Module not found node_modules missing Rebuild: docker compose build --no-cache

Debugging Commands

# View logs for specific service
docker compose logs -f api

# Execute shell in container
docker compose exec api /bin/sh

# Check network connectivity
docker compose exec api ping mongo

# Inspect container
docker inspect mern-api

# View resource usage
docker stats

# Clean up everything
docker compose down -v --rmi all

Lab Assignment

Lab 1 Requirements

Complete the following by Tuesday, January 27:

Core Requirements (80%)

  1. ✅ Working docker-compose.yml with all 4 services
  2. ✅ API with health endpoint and CRUD routes
  3. ✅ Frontend that displays and manages items
  4. ✅ MongoDB persistence with named volume
  5. ✅ Hot reload working for both frontend and API
  6. ✅ README.md with setup instructions

Stretch Goals (+20%)

  • Add input validation
  • Implement error boundary in React
  • Add loading states and error handling
  • Create production Dockerfiles
  • Add environment-specific compose files

Submission Requirements

  1. GitHub Repository with all code
  2. README.md containing:
    • Project description
    • Setup instructions
    • Environment variables needed
    • Screenshots of working app
  3. Working demo - TAs will clone and run

Grading Rubric

Criteria Points
Docker Compose correctly configured 20
API with all CRUD operations 20
Frontend connects and displays data 20
Hot reload working 10
MongoDB persistence 10
Code quality and organization 10
Documentation (README) 10
Total 100

Getting Started with GitHub Classroom

Accept the Assignment

https://classroom.github.com/a/KBx2aztW

. . .

Step 2: Authorize GitHub Classroom

  • Log in with your GitHub account
  • Authorize GitHub Classroom to access your account
  • Accept the assignment

. . .

Step 3: Your Repository is Created

  • GitHub Classroom creates a private repository for you
  • Named: lab1-infrastructure-as-code-<your-username>
  • Contains starter files and instructions

Clone and Work Locally

# Clone your repository
git clone https://github.com/rpi-techfundamentals/lab1-infrastructure-as-code-<your-username>.git

# Navigate into the directory
cd lab1-infrastructure-as-code-<your-username>

# Open in VS Code (optional)
code .

Important: Replace <your-username> with your actual GitHub username.

Git Commit Workflow

The Commit Process

flowchart LR
    A[Working Directory] -->|git add| B[Staging Area]
    B -->|git commit| C[Local Repository]
    C -->|git push| D[Remote Repository]

Your changes flow through three stages before reaching GitHub.

Essential Git Commands

# Check what files have changed
git status

# Stage specific files for commit
git add docker-compose.yml
git add api/src/server.js

# Or stage all changes
git add .

# Commit with a descriptive message
git commit -m "Add API health endpoint and MongoDB connection"

# Push to GitHub
git push origin main

Writing Good Commit Messages

Good Messages

git commit -m "Add CRUD routes for items API"

git commit -m "Fix MongoDB connection timeout issue"

git commit -m "Implement hot reload for frontend"

git commit -m "Add error handling middleware"

Avoid These

git commit -m "stuff"

git commit -m "fixed it"

git commit -m "asdfasdf"

git commit -m "changes"

Rule of thumb: Your message should complete the sentence “This commit will…”

Commit Early, Commit Often

# After each milestone, commit your progress:

# 1. After setting up project structure
git add .
git commit -m "Initialize project structure with API and frontend directories"

# 2. After creating Dockerfiles
git add api/Dockerfile.dev frontend/Dockerfile.dev
git commit -m "Add development Dockerfiles for API and frontend"

# 3. After docker-compose works
git add docker-compose.yml
git commit -m "Configure Docker Compose with all services"

# 4. After implementing API routes
git add api/src/
git commit -m "Implement CRUD endpoints for items"

# 5. After frontend is working
git add frontend/src/
git commit -m "Add React frontend with item management"

# Push all commits to GitHub
git push origin main

Viewing Your Progress

# See commit history
git log --oneline

# Example output:
# a1b2c3d Add React frontend with item management
# e4f5g6h Implement CRUD endpoints for items
# i7j8k9l Configure Docker Compose with all services
# m0n1o2p Add development Dockerfiles
# q3r4s5t Initialize project structure

Your commits tell the story of how you built the lab!

Common Git Issues & Solutions

Issue Solution
“Please tell me who you are” git config --global user.email "you@example.com"
Forgot to commit before making changes Commit now - your changes are still safe
Committed wrong file git reset HEAD~1 to undo last commit
Push rejected git pull first, then git push
Merge conflict Open file, resolve conflicts manually, then commit

Submission Checklist

Before the deadline, ensure:

GitHub Classroom automatically tracks your submission!

The TAs can see your commits and will clone your repository to grade it.

Summary

What We Built Today

┌─────────────────────────────────────────────────────────┐
│              Complete MERN Development Stack            │
│                                                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌────────┐ │
│  │  React   │  │ Express  │  │ MongoDB  │  │ Mongo  │ │
│  │ Frontend │──│   API    │──│ Database │──│Express │ │
│  │  :3000   │  │  :4000   │  │  :27017  │  │ :8081  │ │
│  └──────────┘  └──────────┘  └──────────┘  └────────┘ │
│                                                         │
│  ✓ Hot Reloading    ✓ Persistent Data                  │
│  ✓ Service Discovery    ✓ Health Checks                │
│  ✓ Environment Config   ✓ Volume Mounting              │
└─────────────────────────────────────────────────────────┘

Key Takeaways

  1. Infrastructure as Code makes environments reproducible
  2. Docker Compose simplifies multi-service orchestration
  3. Volume mounting enables hot reload development
  4. Named volumes provide data persistence
  5. Service networking uses container names as hostnames
  6. Health checks ensure proper startup ordering

Next Class: Friday

Architectural Theory: Patterns, System Decomposition, and Project Scoping

  • Deeper dive into architectural patterns
  • How to decompose complex systems
  • Project scoping techniques
  • Project proposal preparation

Questions?

Lab Help: - Office Hours: Tuesday 9-11 AM - Piazza for Q&A - TA sessions (see LMS)

Due Date: January 27, 2026

Good luck with Lab 1!