Why Docker for African Developers
"It works on my machine" is not just a meme — it's a real problem that costs real money. Your Express app runs fine on your Windows laptop but crashes on your teammate's Mac. It works locally but fails on the VPS you're renting for ₦5,000/month. Different Node versions, different OS libraries, different everything.
Docker eliminates this. You package your app with its exact environment — Node version, OS dependencies, environment variables — into a container. That container runs identically everywhere: your machine, your CI pipeline, your production server.
For African startups with distributed teams across Lagos, Nairobi, and Accra, Docker is not optional. It's how you stop wasting time on environment issues.

The Free Stack
| Tool | Purpose | Free Limit | Cost After |
|---|---|---|---|
| Docker Desktop | Build and run containers | Free for personal use | $5/month |
| Railway | Container hosting | $5 free credits | $5/month |
| Fly.io | Alternative hosting | 3 shared VMs free | $1.94/month |
| GitHub Container Registry | Image storage | 500MB free | $0.25/GB |
| Docker Compose | Multi-container local dev | Free forever | Free forever |
Total cost: $0/month
Step 1: Create a Sample Node.js App
mkdir docker-node-app && cd docker-node-app
npm init -y
npm install express pg dotenv
// index.js
import 'dotenv/config'
import express from 'express'
import pkg from 'pg'
const { Pool } = pkg
const app = express()
app.use(express.json())
const pool = new Pool({
connectionString: process.env.DATABASE_URL
})
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
app.get('/api/todos', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM todos ORDER BY created_at DESC')
res.json({ todos: rows })
})
app.post('/api/todos', async (req, res) => {
const { title } = req.body
if (!title) return res.status(400).json({ error: 'Title required' })
const { rows } = await pool.query(
'INSERT INTO todos (title) VALUES ($1) RETURNING *', [title]
)
res.status(201).json({ todo: rows[0] })
})
const PORT = process.env.PORT || 3000
app.listen(PORT, () => console.log('Server running on port ' + PORT))
Step 2: Write a Production Dockerfile
# Dockerfile
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Build (if needed)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build 2>/dev/null || true
# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
# Run as non-root user for security
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
# Copy only production dependencies
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/index.js ./
COPY --from=builder /app/package.json ./
# Set environment
ENV NODE_ENV=production
ENV PORT=3000
USER nodejs
EXPOSE 3000
CMD ["node", "index.js"]
Why multi-stage builds?
| Approach | Image Size | Security |
|---|---|---|
| Single stage (everything) | 1.2GB | Dev dependencies in production |
| Multi-stage (deps only) | 180MB | Only production code and deps |
| Alpine + multi-stage | 120MB | Minimal attack surface |
Step 3: Add .dockerignore
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
README.md
.DS_Store
Without .dockerignore, your node_modules folder (potentially 500MB+) gets copied into the build context, slowing down every build.
Step 4: Docker Compose for Local Development
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp
- NODE_ENV=development
depends_on:
db:
condition: service_healthy
volumes:
- ./index.js:/app/index.js # Hot reload in dev
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
Run the full stack locally:
docker compose up -d
# Create the todos table
docker compose exec db psql -U postgres -d myapp -c "
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
);
"
# Test the API
curl http://localhost:3000/api/health
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"Learn Docker"}'
Step 5: Deploy to Railway
Railway runs Docker containers directly. No Kubernetes, no YAML manifests, no cluster management.
# Install Railway CLI
npm install -g @railway/cli
railway login
# Deploy your Docker container
railway init
railway up
Railway automatically:
- Detects your Dockerfile
- Builds the image
- Runs it on their infrastructure
- Gives you a public URL
- Adds a free PostgreSQL database
Alternative: Deploy to Fly.io
# Install Fly CLI
curl -L https://fly.io/install.sh | sh
fly auth login
fly launch # Follow prompts
fly deploy
Fly.io gives you 3 shared VMs free. Great for low-traffic apps and side projects.
Step 6: Push to Container Registry
For teams and CI/CD, push your image to GitHub Container Registry:
# Build and tag
docker build -t ghcr.io/your-username/docker-node-app:latest .
# Login to GHCR
echo $GITHUB_TOKEN | docker login ghcr.io -u your-username --password-stdin
# Push
docker push ghcr.io/your-username/docker-node-app:latest
Now any server can pull and run your exact image:
docker run -p 3000:3000 -e DATABASE_URL=your-db-url ghcr.io/your-username/docker-node-app:latest
Step 7: Monitoring and Debugging Containers
View Container Logs
# Railway logs
railway logs
# Docker logs locally
docker compose logs -f app
# Inspect a running container
docker inspect <container-id> | head -50
Health Checks
Add a health check to your Dockerfile so the orchestrator knows when your app is actually ready:
# Add to your Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
This pings your health endpoint every 30 seconds. If it fails 3 times in a row, the container is marked unhealthy and gets restarted automatically.
Debugging a Crashing Container
# See why a container exited
docker logs <container-id>
# Run a shell inside a failed container
docker run -it --entrypoint sh ghcr.io/your-username/docker-node-app:latest
# Check resource usage
docker stats
Deployment Cost Comparison
| Platform | Free Tier | Paid Tier | Best For |
|---|---|---|---|
| Railway | $5 credits/month | $5/month hobby | Quick deploys, small apps |
| Fly.io | 3 shared VMs | $1.94/month | Global edge deployment |
| Render | 750 hours/month | $7/month | Auto-scaling web services |
| DigitalOcean App Platform | 3 static sites | $5/month | Simple hosting |
| AWS ECS/Fargate | None (pay from start) | $15+/month | Enterprise workloads |
For an African startup, Railway or Fly.io at $0/month is the obvious choice. You only pay when you're making money.
Common Mistakes
| Mistake | What Happens | Fix |
|---|---|---|
| Running as root user in container | Security vulnerability — attacker gets root access | Add non-root user in Dockerfile |
| Not using multi-stage builds | 1GB+ images with dev dependencies | Use separate deps and runner stages |
| Missing .dockerignore | Slow builds, bloated images | Exclude node_modules, .git, .env |
| Hardcoding environment variables | Different config in every environment | Use .env files and pass via docker run -e |
| Not setting health checks | Container appears "running" but app is crashed | Add HEALTHCHECK or depends_on with condition |
| Ignoring .env in image | Secrets baked into Docker image | Pass secrets at runtime, never in COPY |

