How to Containerize and Deploy a Node.js App with Docker for Free
Build13 min read·March 22, 2026·--

How to Containerize and Deploy a Node.js App with Docker for Free

Docker makes your app run identically everywhere — your laptop, your teammate's machine, and production. Learn to containerize a Node.js app with multi-stage builds and deploy for $0/month.

@
@kivorablog
March 22, 2026
Share

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


ToolPurposeFree LimitCost After
Docker DesktopBuild and run containersFree for personal use$5/month
RailwayContainer hosting$5 free credits$5/month
Fly.ioAlternative hosting3 shared VMs free$1.94/month
GitHub Container RegistryImage storage500MB free$0.25/GB
Docker ComposeMulti-container local devFree foreverFree 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?


ApproachImage SizeSecurity
Single stage (everything)1.2GBDev dependencies in production
Multi-stage (deps only)180MBOnly production code and deps
Alpine + multi-stage120MBMinimal 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

PlatformFree TierPaid TierBest For
Railway$5 credits/month$5/month hobbyQuick deploys, small apps
Fly.io3 shared VMs$1.94/monthGlobal edge deployment
Render750 hours/month$7/monthAuto-scaling web services
DigitalOcean App Platform3 static sites$5/monthSimple hosting
AWS ECS/FargateNone (pay from start)$15+/monthEnterprise 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

MistakeWhat HappensFix
Running as root user in containerSecurity vulnerability — attacker gets root accessAdd non-root user in Dockerfile
Not using multi-stage builds1GB+ images with dev dependenciesUse separate deps and runner stages
Missing .dockerignoreSlow builds, bloated imagesExclude node_modules, .git, .env
Hardcoding environment variablesDifferent config in every environmentUse .env files and pass via docker run -e
Not setting health checksContainer appears "running" but app is crashedAdd HEALTHCHECK or depends_on with condition
Ignoring .env in imageSecrets baked into Docker imagePass secrets at runtime, never in COPY
Read more on Kivora Blog

Read more on Kivora Blog

Get started →