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:
- Copy files to the server via FTP
- Hope the server’s PHP/config matches the local environment
- 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:
- Tests run (TypeScript check, linting, build test)
- Docker image is built using the production Dockerfile
- Image is pushed to GitHub Container Registry
- 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:
- Use named volumes, not bind mounts for databases. Otherwise you lose data on restart.
- Always set resource limits.
--memory="512m"prevents a buggy container from taking down the whole server. - Document your Docker commands. When you haven’t touched a project in six months, you won’t remember how to start it.
- Use Docker networks. Isolated networks between containers are more secure and cleaner.
- Prune regularly.
docker system prune -fin 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.