Miran Arnaut logo Miran Arnaut logo
Development 5 min read

Docker for Web Developers — My Freelance Deployment Workflow

How I use Docker for web development projects: containerized development, CI/CD pipeline, and automated deployments on my own infrastructure.

Docker is no longer a nice-to-have for me — it’s the foundation of my entire development and deployment infrastructure. Every project I ship runs in Docker containers, is built through a CI/CD pipeline, and is deployed automatically to my server infrastructure.

In this post, I’ll walk you through my exact workflow — from local development to production deployment.

Why Docker?

Before Docker, my deployment process looked like this:

  1. Copy files to the server via FTP
  2. Hope the server’s PHP/config matches the local environment
  3. When things break: hours of debugging why it works locally but not on the server

Docker solves this radically: The environment you develop in is identical to the environment the website runs in. No more “it works on my machine.”

The benefits for a freelancer:

  • Reproducibility — Every project has an exact definition of its runtime environment
  • Isolation — Projects don’t interfere with each other
  • Deployment safety — What runs locally runs on the server
  • Scalability — A Docker container can be scaled horizontally if needed

My Docker Setup Per Project

Every project gets a docker-compose.yml in the root directory. Here’s an example for a typical Angular + Astro + Vue.js project:

version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development

  caddy:
    image: caddy:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
    depends_on:
      - app

volumes:
  caddy_data:

Why Caddy? Because Caddy handles automatic SSL certificates via Let’s Encrypt. No manual certificate management required.

The Dockerfile Approach

I use two Dockerfiles: one for development and one for production.

Dockerfile.dev — For local development:

FROM node:22-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

EXPOSE 3000

CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

Dockerfile.prod — For production builds with multi-stage building:

# Stage 1: Build
FROM node:22-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# Stage 2: Production
FROM nginx:stable-alpine

COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

Multi-stage builds ensure the final image contains only what’s needed — no Node.js, no dev dependencies. This saves disk space and reduces the attack surface.

CI/CD with GitHub Actions

My deployment runs fully automated via GitHub Actions. When I push code to the main branch, this happens:

  1. Tests run (TypeScript check, linting, build test)
  2. Docker image is built using the production Dockerfile
  3. Image is pushed to GitHub Container Registry
  4. SSH into server and run deployment script

Here’s my .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/miran-arnaut/project-name:latest

      - name: Deploy to server
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /opt/projects/project-name
            docker compose pull
            docker compose up -d --force-recreate
            docker system prune -f

Deployment Infrastructure

My server setup looks like this:

  • A Virtual Private Server (VPS) running Ubuntu 24.04, 4 GB RAM, 2 CPUs
  • Docker and Docker Compose installed
  • Caddy as reverse proxy with automatic SSL certificates
  • Cloudflare Tunnel for DDoS protection and faster routing
  • Automatic backups via cron job (daily Docker volume backups)

A single docker-compose.yml on the server manages all running projects:

version: '3.8'

services:
  project-a:
    image: ghcr.io/miran-arnaut/project-a:latest
    restart: always
    networks:
      - web

  project-b:
    image: ghcr.io/miran-arnaut/project-b:latest
    restart: always
    networks:
      - web

  caddy:
    image: caddy:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
    networks:
      - web

networks:
  web:
    external: true

volumes:
  caddy_data:

Each project runs isolated in its own container. Caddy routes the domain to the correct container.

Monitoring

Deployment is one thing — knowing everything is running is another. I use:

  • Uptime Kuma (self-hosted) — pings every site every 5 minutes
  • Docker Healthchecks — each container has a healthcheck that triggers auto-restarts
  • Caddy Logs — auto-rotated, kept for 30 days
  • Email notifications — when a container crashes or a site becomes unreachable

What I’ve Learned

Docker has a learning curve. Here’s what I wish I’d known sooner:

  1. Use named volumes, not bind mounts for databases. Otherwise you lose data on restart.
  2. Always set resource limits. --memory="512m" prevents a buggy container from taking down the whole server.
  3. Document your Docker commands. When you haven’t touched a project in six months, you won’t remember how to start it.
  4. Use Docker networks. Isolated networks between containers are more secure and cleaner.
  5. Prune regularly. docker system prune -f in the CI/CD pipeline keeps the server clean.

Is It Worth the Effort?

Absolutely. Yes, Docker adds complexity at the start. But the time I used to spend on manual deployments, config issues, and “works on my machine” debugging is now practically zero.

Each new project gets its Docker setup in 15 minutes. Every deployment runs automatically. Every server is identical to the development environment.

For me as a freelancer managing multiple projects simultaneously, Docker is the difference between chaotic and controlled work.


Want to set up Docker and CI/CD for your next project? I can help. Email me at arnaut@miran.at.

(05) Get in touch

Let's work together

Send me a message or connect on social media.