Dev Containers: From Fundamentals to Implementation
PART 1: Generic Devcontainer Fundamentals
What is a Dev Container?β
A dev container is a Docker container that holds your entire development environmentβprogramming languages, tools, databases, libraries. Instead of installing everything on your machine, you define it once in files, and anyone can spin up an identical environment.
Simple way to think about it:
- Your code lives on your computer (files you edit)
- Your tools live in a container (Docker runs them)
- VS Code acts as a bridge between them
Result: Everyone on your team has the exact same setup.
Why Use Dev Containers?β
| Problem | Dev Container Solution |
|---|---|
| "It works on my machine" but not yours | Everyone gets identical environment |
| New developers spend 2 hours setting up | New dev: open container, 5 minutes done |
| Different results on Windows vs Mac vs Linux | Container runs the same on all OS |
| Conflicting global installations | All tools isolated in one container |
| Hard to reproduce old project environments | Container definition is version-controlled |
Prerequisites: What You Needβ
-
Docker Desktop (Windows/Mac) or Docker Engine (Linux)
- Download
- Windows/Mac note: requires paid license for organizations; free alternative is Podman Desktop
-
VS Code with the Dev Containers extension
# Install from VS Code extension marketplace
# Or command line: code --install-extension ms-vscode-remote.remote-containers -
Git (to clone the project)
How Dev Containers Work: The Flowβ
User Action Dev Container Action
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
VS Code: "Reopen in container"
β
Read devcontainer.json
β
Build/pull Docker image (Dockerfile)
β
Start services (docker-compose.yml)
β
Run init script (init-dev-env.sh)
β
VS Code connects inside container
β
Ready! Edit code on your machine,
container runs it
Step-by-Step: Building a Dev Containerβ
Step 1: Create the .devcontainer/ Folderβ
This is your dev container configuration directory. All devcontainer files go here.
# From project root
mkdir .devcontainer
cd .devcontainer
File structure so far:
your-project/
βββ .devcontainer/ β All devcontainer files live here
Step 2: Create devcontainer.jsonβ
What it does: This is VS Code's instruction manual. It tells VS Code:
- Which Docker image/compose file to use
- Which ports to forward to your machine
- Which extensions to auto-install
- Environment variables to inject
- When to run initialization scripts
Minimal example (generic, works for any project):
{
"name": "My Dev Container",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace/my-project",
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.remote-repositories"
]
}
},
"forwardPorts": [3000, 5000],
"postCreateCommand": "bash .devcontainer/init-dev-env.sh",
"remoteUser": "vscode"
}
What each section means:
| Field | Purpose | Example |
|---|---|---|
name | Display name in VS Code | "My Dev Container" |
dockerComposeFile | Which compose file to use | "docker-compose.yml" |
service | Which service to attach VS Code to | "app" |
workspaceFolder | Where your code is inside container | "/workspace/my-project" |
features | Pre-built environment templates | ghcr.io/devcontainers/features/* |
forwardPorts | Ports to forward to your machine | [3000, 5000] |
postCreateCommand | Script to run after container starts | "./init-dev-env.sh" |
remoteUser | User to run commands as | "vscode" |
File structure so far:
your-project/
βββ .devcontainer/
βββ devcontainer.json β VS Code config
Step 3: Create Dockerfileβ
What it does: A recipe that builds the Docker image. It specifies:
- Base image (starting point)
- System packages to install
- Environment variables
- User to run as
Minimal example (generic Node.js project):
FROM node:20
# Install system packages
RUN apt-get update && apt-get install -y \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /workspace
# Run as non-root user
USER node
What each line means:
| Line | Purpose |
|---|---|
FROM node:20 | Start with Node.js 20 image |
RUN apt-get update... | Install system packages |
WORKDIR /workspace | Set default directory inside container |
USER node | Run as node user (not root) |
Key principle: Your Dockerfile should install everything needed to develop. A new developer runs one command and has all tools ready.
File structure so far:
your-project/
βββ .devcontainer/
βββ devcontainer.json
βββ Dockerfile β Container recipe
Step 4: Create docker-compose.ymlβ
What it does: Defines all the services that make up your development environment. Instead of running one container, you might run:
- Your app container (where VS Code connects)
- A database container
- A cache/message queue
- A mock API server
Minimal example (app + database):
services:
# Main development container (where VS Code connects)
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ../..:/workspace:cached # Your code lives here
command: sleep infinity # Keep container running
environment:
NODE_ENV: development
ports:
- "3000:3000" # Forward port 3000 to your machine
depends_on:
db:
condition: service_started
# Database service
db:
image: postgres:15
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass
POSTGRES_DB: myapp_db
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
# Named volumes persist data between container restarts
volumes:
postgres-data:
What each section means:
| Section | Purpose |
|---|---|
services: | Define all containers |
app: | Your main development container |
build: | Build from Dockerfile in current directory |
volumes: | Mount directories from your machine into container |
ports: | Forward ports from container to your machine |
depends_on: | Wait for other services before starting |
db: | A PostgreSQL database service |
environment: | Environment variables passed to container |
Key principle: Each service is a container. They can talk to each other by service name (db, app, etc.). They can also be reached from your machine via forwarded ports.
File structure so far:
your-project/
βββ .devcontainer/
βββ devcontainer.json
βββ Dockerfile
βββ docker-compose.yml β Service definitions
Step 5: Create init-dev-env.sh (Optional but Recommended)β
What it does: One-time setup script that runs after the container starts. Common tasks:
- Install development tools globally
- Create/migrate databases
- Verify tool versions
- Download certificates
Minimal example (generic):
#!/bin/bash
set -e
echo "π§ Initializing dev container..."
# Verify tools are installed
echo "π¦ Checking tools..."
node --version
npm --version
# Install project dependencies
echo "π¦ Installing dependencies..."
cd /workspace/my-project
npm install
echo "β
Dev container ready!"
Key sections:
| Line | Purpose |
|---|---|
#!/bin/bash | This is a bash script |
set -e | Exit if any command fails |
echo "..." | Print messages (guides user) |
npm install | Install dependencies |
Important: Make the script executable:
chmod +x .devcontainer/init-dev-env.sh
File structure so far:
your-project/
βββ .devcontainer/
βββ devcontainer.json
βββ Dockerfile
βββ docker-compose.yml
βββ init-dev-env.sh β Setup script
Step 6: Test Itβ
You now have a working devcontainer. Test it:
- Open VS Code in your project directory
- Press
F1(orCmd+Shift+Pon Mac) - Type:
Dev Containers: Reopen in Container - Wait for container to build (first time: 2β5 minutes)
- Open a terminal inside VS Code (
Ctrl+backtick) - Verify tools:
node --version
npm --version
If you see the expected versions, it works! β
Key Concepts to Rememberβ
Volumesβ
Volumes link your machine to the container. Two types:
- Bind mount (
../..:/workspace:cached) β Your code on your machine is visible inside container. Edit on machine β code updates inside container. - Named volume (
postgres-data:) β Docker manages storage. Used for databases or build artifacts.
Portsβ
Ports forward from container to your machine so you can access services.
Example: "3000:3000" means:
- Container port 3000 β Machine port 3000
- Access from your browser:
http://localhost:3000
Service Namesβ
Inside the container, services talk to each other by name:
# Inside container
curl http://db:5432 # Reaches the 'db' service
curl http://app:3000 # Reaches the 'app' service
From your machine:
# From your machine
curl http://localhost:5432 # Reaches forwarded port
The depends_on Ruleβ
If one service needs another, list it:
app:
depends_on:
db:
condition: service_healthy # Wait until db is healthy
This ensures the database starts before your app tries to connect.
PART 2: SIZO Implementation
How We Applied the Blueprint to SIZOβ
The SIZO project is a full-stack application (backend + frontend + database + mocking). We built a devcontainer that:
- Provides a complete dev environment (.NET 8, Node.js, PostgreSQL)
- Manages multiple services (backend API, frontend dev server, database, mock APIs)
- Handles corporate network requirements (SEED Cloudflare TLS inspection)
- Enables offline development (mock APIs instead of real external services)
- Works with and without the container (dual-mode design)
SIZO Folder & File Structureβ
sizo-application/
βββ Backend/ # .NET 8 backend
β βββ Properties/launchSettings.json # Two profiles: Backend, Backend-Container
β βββ appsettings.Development.json # Dev config (uses http://wiremock:8080)
β βββ ...
βββ frontend/ # React frontend
β βββ package.json # Two scripts: start, start:container
β βββ ...
βββ api-mocking/
β βββ mappings/ # WireMock mock definitions (JSON)
β β βββ proxy-to-sizo-api.json # Proxies to localhost (bare-metal) or app (container)
β βββ files/ # Mock response files
βββ .devcontainer/ # β Dev container config
βββ devcontainer.json
βββ Dockerfile
βββ docker-compose.yml
βββ init-dev-env.sh
βββ wiremock/
β βββ proxy-to-sizo-api.json # Container override (targets app:*)
βββ README.md
Why this structure?
.devcontainer/= all container config (safe to version-control)Backend/,frontend/,api-mocking/= shared (work with or without container)- Container-specific files override shared files (e.g., WireMock proxy mapping)
Step-by-Step: SIZO's Dev Container Filesβ
File 1: devcontainer.json (SIZO Version)β
What we added beyond the generic version:
{
"name": "Sizo Backend",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace/sizo-application",
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {},
"ghcr.io/devcontainers/features/node:1": {
"version": "lts"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-dotnettools.csdevkit",
"humao.rest-client",
"dbaeumer.vscode-eslint"
],
"settings": {
"csharp-dev-tools.roslyn.projectOrSolutionFiles": [
"/workspace/sizo-application/sizo-application.sln"
]
}
}
},
"forwardPorts": [3000, 5001],
"postCreateCommand": "bash /workspace/sizo-application/.devcontainer/init-dev-env.sh",
"remoteUser": "vscode",
"remoteEnv": {
"DOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLER": "0",
"NODE_TLS_REJECT_UNAUTHORIZED": "0",
"DOTNET_ENVIRONMENT": "Development",
"ASPNETCORE_ENVIRONMENT": "Development",
"SIZO_DB_CONN_STRING": "Host=local-db;Port=5432;Database=sizo_db;Username=sizouser;Password=sizoPassword"
}
}
Why these additions?
| Addition | Why |
|---|---|
features.node:1 | Need Node.js for frontend development |
extensions | Auto-install C# Dev Kit, REST Client, ESLint for our stack |
roslyn.projectOrSolutionFiles | Tell C# Dev Kit where the solution file is |
forwardPorts: [3000, 5001] | Frontend (3000) and backend (5001) ports |
remoteEnv variables | Configure .NET for SEED network and container database |
Key difference from generic: We added specific extensions and environment variables for our tech stack (.NET, React, PostgreSQL).
File 2: Dockerfile (SIZO Version)β
What we added beyond the generic version:
# SEED-compliant dev container with .NET 8
# Includes Cloudflare CA certificate for TLS inspection
# Node.js is installed via devcontainer feature (see devcontainer.json)
FROM mcr.microsoft.com/devcontainers/dotnet:8.0
# Install additional system dependencies for C# Dev Kit
RUN apt-get update && apt-get install -y \
libicu-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Cloudflare CA certificate (SEED requirement)
RUN curl -fsSk -o /usr/local/share/ca-certificates/Cloudflare_CA.crt \
https://seed-general-public-files.s3.ap-southeast-1.amazonaws.com/seed-cloudflare-root-certs/Cloudflare_CA.pem \
&& update-ca-certificates
# Set CA bundle environment variables for various tools
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt \
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
USER vscode
Why these additions?
| Addition | Why |
|---|---|
FROM mcr.microsoft.com/devcontainers/dotnet:8.0 | Base image with .NET 8 pre-installed |
libicu-dev | Required by C# Dev Kit for language support |
| Cloudflare CA certificate | SEED network uses Cloudflare TLS inspection; we need to trust it |
ENV variables | Tell all tools (.NET, Node, Python, AWS CLI) to trust the SEED certificate |
Why this matters: Without the Cloudflare certificate, any HTTPS request inside the container fails. This is a SEED-specific requirement.
File 3: docker-compose.yml (SIZO Version)β
What we defined:
services:
# Main app container (where VS Code connects)
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ../..:/workspace:cached
- frontend-node-modules:/workspace/sizo-application/frontend/node_modules
command: sleep infinity
environment:
ASPNETCORE_ENVIRONMENT: Development
DOTNET_ENVIRONMENT: Development
SIZO_DB_CONN_STRING: "Host=local-db;Port=5432;Database=sizo_db;Username=sizouser;Password=sizoPassword"
depends_on:
local-db:
condition: service_healthy
wiremock:
condition: service_started
# PostgreSQL database
local-db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: sizo_db
POSTGRES_USER: sizouser
POSTGRES_PASSWORD: sizoPassword
volumes:
- postgres-data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sizouser -d sizo_db"]
interval: 5s
timeout: 5s
retries: 10
# WireMock β Mock API server
wiremock:
image: wiremock/wiremock:2.33.2-alpine
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ../api-mocking/mappings:/home/wiremock/mappings:ro
- ../api-mocking/files:/home/wiremock/__files:ro
# Container override: use Docker Compose service names (app:*) instead of localhost
- ./wiremock/proxy-to-sizo-api.json:/home/wiremock/mappings/proxy-to-sizo-api.json:ro
command: ["--global-response-templating", "--verbose"]
volumes:
postgres-data:
frontend-node-modules:
Three services explained:
| Service | Purpose | Port | Why |
|---|---|---|---|
app | Your backend & frontend dev environment | 5001, 3000 | Where VS Code connects; where you run code |
local-db | PostgreSQL 17 database | 5432 | Stores app data; initialized automatically |
wiremock | Mock API server | 8080 | Simulates external APIs; pre-loaded with mock responses |
Key design choices:
depends_onwithservice_healthyβ App waits for database to be fully ready before startingfrontend-node-modulesvolume β Stores node_modules on Docker volume (faster than bind mount on Windows)postgres-datavolume β Persists database between restarts- WireMock overlay mount β
.devcontainer/wiremock/proxy-to-sizo-api.jsonoverrides the committed file so container and bare-metal use different host names
File 4: init-dev-env.sh (SIZO Version)β
What the setup script does:
#!/bin/bash
set -e
echo "π§ Initializing SEED dev container..."
# Trust the workspace repo (fixes git UID mismatch errors)
git config --global --add safe.directory /workspace/sizo-application
# Fix symlinks and line endings (Windows host issue)
git config core.symlinks true
git checkout HEAD -- Backend/DocumentGenerator/QualifyingActivities 2>/dev/null || true
git config core.autocrlf input
git add --renormalize . 2>/dev/null || true
# Verify tools
echo "π¦ Verifying tools..."
echo " .NET: $(dotnet --version)"
echo " Node: $(node --version)"
echo " npm: $(npm --version)"
# Verify CA certificate works
echo "π Verifying CA certificate..."
if curl -fsSI https://www.npmjs.com/ > /dev/null; then
echo " β
CA certificate working"
else
echo " β οΈ Warning: Cannot reach npmjs.com"
fi
# Generate HTTPS dev cert
echo "π Generating HTTPS dev certificate..."
if ! dotnet dev-certs https --check --trust 2>/dev/null; then
dotnet dev-certs https --trust 2>/dev/null || true
fi
# Install EF Core tools
echo "π Installing EF tools..."
if ! dotnet ef --version > /dev/null 2>&1; then
dotnet tool install --global dotnet-ef --version 8.0.* 2>/dev/null || true
export PATH="$PATH:$HOME/.dotnet/tools"
fi
# Apply database migrations
echo "π Applying database migrations..."
cd /workspace/sizo-application/Backend
export SIZO_DB_CONN_STRING="Host=local-db;Port=5432;Database=sizo_db;Username=sizouser;Password=sizoPassword"
dotnet ef database update || echo "β οΈ Migration may already be up to date"
# Fix node_modules ownership
echo "π Fixing node_modules volume ownership..."
sudo chown -R vscode:vscode /workspace/sizo-application/frontend/node_modules 2>/dev/null || true
echo "β
Dev container ready!"
What each section does:
| Section | Purpose | Why SIZO Specific |
|---|---|---|
git config safe.directory | Fixes "dubious ownership" error | Container UID differs from host UID on Linux |
| Fix symlinks/CRLF | Windows hosts mount with wrong line endings | Windows issue |
| Verify tools | Confirm .NET, Node, npm installed | Tech stack verification |
| Verify CA cert | Test SEED certificate works | SEED network requirement |
| HTTPS dev cert | Generate self-signed cert for local HTTPS | Backend runs HTTPS by default |
| EF Core tools | Install database migration tool | .NET backend uses EF migrations |
| Database migrations | Create/update database schema | Automatic setup for new developers |
| node_modules ownership | Fix Docker volume permissions | Container runs as root, app runs as vscode |
Understanding SIZO's Dual-Mode Designβ
This is the key innovation: files work with or without the container.
How It Worksβ
| File | Without Container (localhost) | Inside Container (Docker service names) |
|---|---|---|
Backend/launchSettings.json | Backend profile β localhost:5432 | Backend-Container profile β local-db:5432 |
frontend/package.json | npm start β HTTPS, localhost | npm run start:container β HTTP, Docker DNS |
api-mocking/mappings/proxy-to-sizo-api.json | Proxies to http://localhost:5001 | Overridden by .devcontainer/wiremock/... β http://app:5001 |
Why This Mattersβ
Without dual-mode:
- Developers using the container get a working setup
- Developers without the container break (hard-coded
local-dbdoesn't exist locally) - Force everyone onto containers (not flexible)
With dual-mode:
- Container users:
dotnet run --launch-profile Backend-Container - Bare-metal users:
dotnet run(default profile) - Both work; both happy; flexible choice
Issues We Faced & How We Solved Themβ
Issue 1: TLS/SSL Certificate Errors in SEED Networkβ
First: What is TLS/SSL and Why Do We Need It?β
TLS/SSL = Secure encryption for the internet
Think of the internet like a postal service:
- Without TLS: You send a postcard. Anyone handling it can read it. Anyone could change it.
- With TLS: You send an encrypted letter. Only the intended recipient can open it. Tampering is detected.
TLS = Transport Layer Security (the newer name)
SSL = Secure Sockets Layer (older name, mostly replaced by TLS but people still say "SSL")
Together they do two things:
| What | Why |
|---|---|
| Encrypt traffic | Protect data traveling over the network from being read by others |
| Verify identity | Prove the server you're talking to is actually who it claims to be (not a fake) |
How TLS/SSL Works (The Flow)β
1. You (client) Server
"Can I talk to example.com?"
β
Server sends back: "Here's my certificate (proof I'm example.com)"
β
2. You check:
"Is this certificate signed by someone I trust?"
(Look in your certificate store)
β
3. Certificate is trusted β
"Encrypted connection established"
β β (now all communication is encrypted)
What's in a certificate?
Certificate = ID + Signature
ββ Domain name: "example.com"
ββ Public key: (math key for encryption)
ββ Issued by: "VeriSign" (a trusted authority)
ββ Valid from: 2024-01-01
ββ Valid until: 2025-01-01
ββ Digital signature: (proof this is real, not forged)
Where certificates come from:
- Trusted authorities (VeriSign, Let's Encrypt, etc.) issue them
- Your computer has a certificate store β a list of authorities you trust
- When a server sends a certificate, you check: "Was this signed by someone in my list?"
- If yes β trusted β encrypted connection β
- If no β untrusted β connection blocked β
Why SEED Network Causes Certificate Errorsβ
Normal setup:
Your computer β [internet] β Real server (npmjs.com)
sends: "I'm npmjs.com, signed by trusted authority"
you: β
"I trust that authority, proceed"
SEED network setup (with corporate firewall/proxy):
Your computer β [SEED proxy - Cloudflare] β [internet] β Real server
β
The proxy intercepts ALL HTTPS traffic
The problem:
- You try to connect to
npmjs.com - Cloudflare proxy intercepts (inspects/logs the traffic for security)
- Cloudflare re-encrypts and forwards your request
- But your computer sees Cloudflare's certificate, not npmjs.com's
- Your certificate store doesn't include Cloudflare's certificate
- You reject it: β "I don't trust this certificate!"
Result: Connection fails
Error: unable to get local issuer certificate
(means: "I don't recognize who signed this certificate")