Skip to main content

🧰 Weekend Project: Version Control & Docker

Time to put the whole module to work. Over a weekend, you'll scaffold a real full-stack app and wrap it in the professional plumbing that makes teams productive: a clean Git branching workflow, a reproducible Docker Compose stack, a VS Code dev container, and a GitHub Actions pipeline that runs on every push. By Sunday night you'll have a repository that any teammate can clone and run with a single command.

🎯 Learning Objectives

By the end of this project, you will be able to:

  • Initialize a multi-service repository with a sensible structure, .gitignore, and a feature-branch workflow
  • Author a docker-compose.yml that runs a backend API, a frontend, and a database as networked services
  • Configure a VS Code dev container so the whole team develops inside the same environment
  • Write a GitHub Actions CI workflow that installs, lints, and tests on every push
  • Evaluate your own work against a concrete "what good looks like" checklist

Estimated Time: 4–8 hours (a weekend)  β€’  Difficulty: Intermediate

Hands-on: This entire lesson is the build. Work through the five milestones in order and commit as you go.

In This Lesson

The Brief & How to Work

Your goal is not to write a lot of application code β€” it's to build the environment and workflow around a small app so that it is reproducible, versioned, and continuously tested. The app itself can stay deliberately tiny: a backend that returns a health-check message, a frontend that displays it, and a database that proves the network wiring works.

Treat this like a professional would. Work in small, well-labelled Git commits. Do each unit of work on a branch, open a pull request, and merge it. Don't try to do everything at once β€” the milestones below are designed so each one leaves you with something that runs.

πŸ“– What you'll produce

A repository containing a backend/, a frontend/, a docker-compose.yml, a .devcontainer/ folder, a .github/workflows/ CI file, and a real README.md.

A workflow: feature branches merged via pull requests, with CI green before merge.

A "one command up" experience: a fresh clone runs with docker compose up.

πŸ’‘ Approach it methodically. Understand the goal, plan the pieces, build one milestone at a time, then review against the checklist. That "understand β†’ plan β†’ execute β†’ review" loop (Polya's classic problem-solving steps) is exactly how experienced developers tackle unfamiliar setup work.

Milestone Roadmap

Five milestones take you from an empty folder to a continuously tested, containerized app. Each depends on the one before it.

flowchart LR M1[Milestone 1
Repo & Git flow] --> M2[Milestone 2
Docker Compose] M2 --> M3[Milestone 3
Dev container] M3 --> M4[Milestone 4
CI pipeline] M4 --> M5[Milestone 5
Docs & review]
MilestoneDeliverableRough time
1 β€” Repository & Git workflowInitialized repo, structure, .gitignore, first feature branch45–60 min
2 β€” Compose the stackBackend + frontend + database running via Compose90–120 min
3 β€” Dev container.devcontainer so VS Code reopens inside the container45–60 min
4 β€” CI pipelineGitHub Actions installs, lints, and tests on push60–90 min
5 β€” Document & reflectREADME, screenshots, reflection notes30–45 min

Milestone 1 β€” Repository & Git Workflow

Start with a clean, well-structured repository and a branching habit you'll keep for the rest of the project.

Create the structure

mkdir weekend-stack && cd weekend-stack
git init
mkdir backend frontend
# a top-level README you'll fill in at Milestone 5
touch README.md docker-compose.yml

Add a real .gitignore

Never commit dependencies, build output, or secrets. A minimal but honest ignore file:

# dependencies
node_modules/
# build output
dist/
build/
# environment & secrets
.env
.env.local
# logs and OS cruft
*.log
.DS_Store

Adopt a feature-branch workflow

Keep main always working. Do each piece of work on a short-lived branch, then merge it back through a pull request.

# commit the skeleton on main
git add .
git commit -m "chore: scaffold repo structure and gitignore"

# start the first feature on its own branch
git switch -c feat/compose-stack
gitGraph commit id: "scaffold" branch feat/compose-stack commit id: "add compose" commit id: "add services" checkout main merge feat/compose-stack branch feat/devcontainer commit id: "add devcontainer" checkout main merge feat/devcontainer

⚠️ Common trap

If you accidentally committed node_modules/ before adding .gitignore, Git keeps tracking it. Fix it with git rm -r --cached node_modules, then commit. The --cached flag removes it from Git without deleting it from disk.

Milestone 2 β€” Compose the Stack

Now make three services run together with one command. We'll use a Node/Express backend, a static frontend, and a Postgres database β€” but the shape is identical if you swap in Python or PHP.

A tiny backend

backend/server.js β€” a single health-check endpoint. Modern syntax: const, arrow functions, and a route that reports the database host it was told to use.

const express = require('express');

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

app.get('/api/health', (req, res) => {
  res.json({
    status: 'ok',
    db: process.env.DB_HOST || 'not configured',
    time: new Date().toISOString(),
  });
});

app.listen(PORT, () => {
  console.log(`API listening on http://localhost:${PORT}`);
});

backend/package.json β€” declare the start scripts so the container knows how to run and test the app.

{
  "name": "backend",
  "version": "1.0.0",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^4.19.2"
  }
}

Dockerfile for the backend

backend/Dockerfile β€” copy manifests first so Docker can cache the dependency layer.

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 4000
CMD ["npm", "start"]

Wire it together with Compose

docker-compose.yml β€” three services on one network. Note the modern Compose file needs no version: key. The backend reaches the database using the service name db as its hostname.

services:
  backend:
    build: ./backend
    ports:
      - "4000:4000"
    environment:
      DB_HOST: db
      DB_USER: appuser
      DB_PASSWORD: apppass
      DB_NAME: appdb
    depends_on:
      - db

  frontend:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./frontend:/usr/share/nginx/html:ro

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: apppass
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

A frontend that proves the wiring

frontend/index.html β€” fetches the backend health check and shows it. This confirms the browser can reach the API.

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Weekend Stack</title></head>
<body>
  <h1>Weekend Stack</h1>
  <pre id="out">loading…</pre>
  <script>
    fetch('http://localhost:4000/api/health')
      .then((r) => r.json())
      .then((data) => {
        document.getElementById('out').textContent = JSON.stringify(data, null, 2);
      })
      .catch((err) => {
        document.getElementById('out').textContent = 'API unreachable: ' + err;
      });
  </script>
</body>
</html>

Bring it up

docker compose up --build

Visit http://localhost:8080 and you should see:

{
  "status": "ok",
  "db": "db",
  "time": "2026-07-30T12:00:00.000Z"
}
The three Compose services on one network A browser calls the frontend and backend; the backend talks to the database over the internal Compose network using the service name db as a hostname. Browser localhost frontend (nginx) :8080 β†’ :80 backend (node) :4000 db (postgres) host = "db" DB_HOST=db
Figure 1 β€” Compose puts every service on a shared network. Services address each other by name (db), while your machine reaches them through the published ports.

Milestone 3 β€” Dev Container

A dev container guarantees every teammate develops with the same Node version, extensions, and settings β€” no more "works on my machine." VS Code reads a .devcontainer/devcontainer.json and reopens your project inside a container.

{
  "name": "Weekend Stack",
  "dockerComposeFile": "../docker-compose.yml",
  "service": "backend",
  "workspaceFolder": "/app",
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode"
      ],
      "settings": {
        "editor.formatOnSave": true
      }
    }
  },
  "forwardPorts": [4000, 8080],
  "postCreateCommand": "npm install"
}

Open the folder in VS Code, then run Dev Containers: Reopen in Container from the command palette. VS Code builds (or reuses) the backend service, installs extensions inside it, and opens a terminal that lives in the container.

πŸ’‘ Why point the dev container at a Compose service?

By reusing the same docker-compose.yml, your editor's container and your running stack are the same environment. When you start a debug session, the backend can already reach db by name because it's on the Compose network.

Milestone 4 β€” CI with GitHub Actions

Continuous Integration runs your checks automatically on every push, so a broken commit is caught before it reaches main. Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: ["**"]
  pull_request:
    branches: ["main"]

jobs:
  backend:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: backend
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
          cache-dependency-path: backend/package-lock.json
      - run: npm ci
      - run: npm test

Add one small test so npm test actually verifies something. backend/health.test.js using Node's built-in test runner (no extra dependency):

const { test } = require('node:test');
const assert = require('node:assert');

test('health payload is well formed', () => {
  const payload = { status: 'ok', db: 'db' };
  assert.strictEqual(payload.status, 'ok');
  assert.ok(payload.db, 'db host should be set');
});

Commit, push your feature branch, and open a pull request. GitHub runs the workflow and shows a green check when it passes.

flowchart LR P[git push] --> T{CI runs
install Β· test} T -->|pass βœ…| M[Safe to merge PR] T -->|fail ❌| F[Fix & push again] F --> T

⚠️ Use npm ci, not npm install, in CI

npm ci installs exactly what the lockfile pins β€” deterministic and fast β€” and fails loudly if package.json and package-lock.json disagree. That's exactly what you want a pipeline to catch.

Milestone 5 β€” Document & Reflect

A project isn't done until someone else can run it. Write a README.md that a newcomer could follow cold.

# Weekend Stack

A minimal full-stack app used to practice Git, Docker Compose,
dev containers, and CI.

## Stack
- Backend: Node.js + Express
- Frontend: static HTML served by nginx
- Database: PostgreSQL

## Run it
```bash
docker compose up --build
```
- Frontend: http://localhost:8080
- API health: http://localhost:4000/api/health

## Develop
Open in VS Code and choose "Reopen in Container".

## Workflow
Feature branches β†’ pull request β†’ CI green β†’ merge to `main`.

Reflect (write 4–6 sentences in the README)

  • What was the hardest part to get working, and how did you diagnose it?
  • Which milestone gave the biggest payoff for the effort?
  • One thing you'd add next: automated end-to-end tests? A production Dockerfile? A staging deploy?

πŸ’‘ Stretch goals (optional)

  • Swap the backend for Flask or Laravel β€” same Compose shape, different Dockerfile.
  • Add a lint step (ESLint/Prettier) to the CI job.
  • Add a healthcheck to the db service and make backend wait for it.
  • Cache Docker layers in CI to speed up builds.

What Good Looks Like

Before you call it finished, compare your work against these standards. This is roughly how a reviewer or instructor would grade it.

Area❌ Needs workβœ… What good looks like
Git history One giant "final" commit; secrets or node_modules committed Small, purposeful commits with clear messages; work merged via PRs; nothing ignored is tracked
Compose Services can't reach each other; hard-coded IPs Services addressed by name; one docker compose up starts everything
Dev container Absent, or doesn't open Reopen-in-container works and installs the pinned toolchain + extensions
CI No workflow, or it's permanently red Green on push; uses npm ci; a real (if small) test runs
Docs Empty or default README Clear run steps a newcomer can follow, plus a short reflection

βœ… The one-sentence test

If a teammate can clone your repo, run docker compose up, and see the health check in the browser without asking you a single question, you've built it right.

Completion Checklist

Tick these off before you consider the weekend project done:

  • ☐ Repo initialized with backend/, frontend/, and a working .gitignore
  • ☐ At least two feature branches merged into main via pull requests
  • ☐ docker compose up --build starts backend, frontend, and database
  • ☐ The frontend page displays the backend's health JSON
  • ☐ Backend reaches the database by its service name (db)
  • ☐ .devcontainer/devcontainer.json reopens the project in a container
  • ☐ .github/workflows/ci.yml runs and passes on push
  • ☐ CI uses npm ci and runs at least one test
  • ☐ README.md has run instructions + a short reflection
  • ☐ No secrets, node_modules, or build output tracked in Git

🎯 Quick Quiz

Question 1: In a Docker Compose stack, how does the backend service reach the db service?

Question 2: Why does the CI workflow use npm ci instead of npm install?

Question 3: You committed node_modules/ by mistake before adding .gitignore. What removes it from version control while keeping it on disk?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • The value of this project is the environment and workflow, not a lot of app code.
  • Git feature branches + PRs keep main working and make review easy.
  • Docker Compose runs multiple services on one network; they address each other by service name.
  • Dev containers give everyone the same toolchain; CI catches breakage on every push.
  • "Done" means a teammate can clone and run it with one command.

πŸ“š Further Reading

πŸš€ What's Next?

You've closed out Module 2 with a reproducible, tested project. Next, in Module 3, we zoom back out to the web itself β€” how browsers, servers, HTTP, and the wider technology ecosystem fit together β€” so your future features rest on a solid mental model.

πŸŽ‰ Weekend well spent!

You now have a repo that runs anywhere with one command. That habit alone will save you countless hours.