It Works on My Machine#
Famous last words. You’ve built a service, it runs perfectly on localhost, and now you need to get it onto that Docker infrastructure without the “works on my machine” problem. The answer is a Dockerfile — a recipe that produces the exact same environment every time, regardless of where it’s built.
The Two-Stage Approach#
A naive Dockerfile installs everything — build tools, dev dependencies, test frameworks — and ships it all to production. This works, but your production image is bloated with stuff it doesn’t need. The fix is multi-stage builds: one stage to build, another to run.
# Stage 1: Build with ALL dependencies
FROM node:22-alpine AS builder
WORKDIR /src
COPY package*.json ./
RUN npm ci # install everything (including devDependencies)
COPY . .
RUN npm run build # compile/bundle the app
# Stage 2: Runtime with ONLY production dependencies
FROM node:22-alpine
WORKDIR /src
COPY package*.json ./
RUN npm ci --omit=dev # production deps only
COPY --from=builder /src/dist ./dist
USER node # don't run as root in production
CMD ["npm", "start"]
Stage 1 has everything needed to build. Stage 2 only has what’s needed to run. The final image is smaller, has fewer security vulnerabilities (less software = less attack surface), and starts faster.
Compose Yourself#
Once you have a Dockerfile, Docker Compose ties everything together:
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: my-app
restart: always
environment:
- DATABASE_URL=${DATABASE_URL}
- NODE_ENV=production
networks:
- frontend
labels:
- "traefik.enable=true"
- "traefik.http.routers.my-app-sec.entrypoints=https"
- "traefik.http.routers.my-app-sec.rule=Host(`my-app.example.com`)"
- "traefik.http.routers.my-app-sec.tls=true"
- "traefik.http.services.my-app.loadbalancer.server.port=3000"
- "traefik.docker.network=frontend"
networks:
frontend:
external: true
The Traefik labels handle routing (covered in the previous post
), restart: always ensures the container comes back after a crash or reboot, and the frontend network connects it to Traefik. Environment variables come from a .env file that never gets committed to Git (please don’t commit your database credentials).
The Pipeline#
Building locally and SSH-ing to run docker compose up is fine for a side project. But I wanted to practice proper CI/CD — test, build, deploy, automatically. GitHub Actions handles this, running on a self-hosted runner on the same machine (because the containers deploy there anyway).
The pipeline has two stages:
Test — runs tests inside a Docker container. If the tests fail, the container exits with a non-zero code, and the pipeline stops. No deployment happens.
test-in-docker:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build test image
run: docker build -f test.Dockerfile -t my-app-test .
- name: Run tests
run: docker run --name app_test --rm my-app-test
- name: Cleanup
if: always()
run: docker rm -f app_test || true
Deploy — only runs if tests pass. Tears down the existing container, rebuilds, and brings it back up.
deploy:
runs-on: self-hosted
needs: test-in-docker
if: ${{ needs.test-in-docker.result == 'success' }}
steps:
- name: Tear down
run: docker compose -f compose.prod.yaml down
- name: Build and deploy
run: docker compose -f compose.prod.yaml up --build -d
The whole flow: push code → tests run in Docker → tests pass → old container torn down → new one built and deployed. About a minute end to end. No SSH required.
Is it sophisticated? Not really. There’s no blue-green deployment, no canary releases, no rollback strategy beyond “revert the commit and push again.” But for a homelab running on a single machine, it gets the job done.
The Ceiling#
This setup works well for one machine. One laptop, a few services, one Docker daemon. But what happens when you outgrow that? When you need multiple machines, automatic failover, rolling updates without downtime, and the ability to scale individual services independently?
That’s where Docker Compose hits its ceiling and Kubernetes enters the picture. But that’s the next post .
