Web and app development studio

Netspark.ru

Smart planner for travel agents

Docturist

Platform for Telegram bots

Botopotamus

Virtualizing local development, docker-compose and traefik

Virtualizing local development, docker-compose and traefik

A long time ago I described a fairly simple way to virtualize the projects I work on, so it's easy to switch between them and they don't get in each other's way. 7 years have passed and of course the approach has evolved. Back then, using separate containers started with docker run was faster and more convenient, so I didn't write Dockerfiles and docker-compose files. But progress did catch up with me on this one and won. Now I use docker compose for local development too.

Still, I try not to use tools with all sorts of presets like lando or ddev. Mostly because when I ran into them, they worked fine — until I needed to change something in them or do something non-standard. After that the brain-melting began, sometimes so bad that it was easier to rebuild everything myself right away. Besides, if you build "clean" environments, you have a chance to better understand how it all works, and if you use lando, forge, ddev and the like — the opposite.

So, why docker compose is better.

First, of course, all the settings are in plain sight, in files. That means you can hand the file to someone and they'll easily rebuild that environment on their machine. Files are clearer — you can always see what you're changing and where. Files can, after all, be pushed to git.

Second, maintainability. The individual images I used were wonderful in their own way — but only until it turned out that an image was built once upon a time on Debian 9 and now you need to install PHP 8.5 on it. Not that it's super hard, but it takes time and, even if you do it rarely, gets old to death. With the file-based approach, you just change the PHP and package versions in the settings, and it rebuilds itself.

Third, with the development of AI tools it became obvious that asking a neural colleague to work with docker-compose.yml and Dockerfile is much easier than asking it to write instructions for upgrading an old container from the console. And in general, Claude and friends are a great help in figuring things out when something about a docker environment is unclear.

And what was the old way better at to begin with?

First of all, it put each of my new dev containers on a separate IP. 172.17.0.2, 172.17.0.3 and so on. So the containers didn't conflict with each other, you just had to remember (at the moment) what was on which IP, and everything worked. Whereas with the new nginx containers with local domains, you want to access dev sites as, say, site1.local, site2.local, netspark.local and so on — and not memorize IP addresses anymore. But then the nginx instances of different projects start conflicting with each other and won't run in parallel.

And the desire to run several projects in parallel, again thanks to the development of neural networks, comes up more and more often: in one the machine is thinking about a task, in another — you are. In one you make an edit, in another the test suite takes 20 minutes to run. So for a while I combined the approaches. Old projects I traditionally ran on single handcrafted containers, and new ones I already set up on docker compose. Here's an example docker-compose.yml for a new Laravel project:

  • nginx,
  • PHP 8.3,
  • MySQL,
  • Redis for cache, queues and deduplication,
  • Supervisor to keep horizon and reverb running,
  • Horizon to manage queues,
  • Reverb for websockets,
  • Mailhog for catching test mail
  • and self-signed certificates so it works over https.
services:
  app:
    build:
      context: .
      dockerfile: ./build/app/Dockerfile
    container_name: laravel_site
    restart: unless-stopped
    volumes:
      - .:/var/www/html
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
      - MAIL_HOST=mailhog
      - TZ=Europe/Moscow
      - MYSQL_DISABLE_SSL_VERIFY=true
      - REVERB_HOST=reverb
      - REVERB_PORT=8080
      - REVERB_SCHEME=http
    depends_on:
      - mysql
      - redis
      - mailhog
    networks:
      - laravel_site_network

  webserver:
    build:
      context: .
      dockerfile: ./build/nginx/Dockerfile.local
    container_name: laravel_site_nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - .:/var/www/html
      - ./build/nginx/conf.d/app.local.conf:/etc/nginx/conf.d/app.conf:ro
    environment:
      - TZ=Europe/Moscow
    depends_on:
      - app
      - reverb
    networks:
      - laravel_site_network

  mysql:
    image: mysql:8.0
    container_name: laravel_site_mysql
    restart: unless-stopped
    command: --require_secure_transport=OFF
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}
      TZ: Europe/Moscow
    volumes:
      - mysql_laravel_site_data:/var/lib/mysql
    ports:
      - "3306:3306"
    networks:
      - laravel_site_network

  redis:
    image: redis:alpine
    container_name: laravel_site_redis
    restart: unless-stopped
    environment:
      - TZ=Europe/Moscow
    networks:
      - laravel_site_network

  mailhog:
    image: mailhog/mailhog
    container_name: laravel_site_mailhog
    restart: unless-stopped
    ports:
      - "1025:1025"   # SMTP
      - "8025:8025"   # Web UI
    networks:
      - laravel_site_network

  reverb:
    build:
      context: .
      dockerfile: ./build/app/Dockerfile
    container_name: laravel_site_reverb
    restart: unless-stopped
    command: php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080 --debug # so debug messages end up in the log
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
      - TZ=Europe/Moscow
      - REVERB_SERVER_HOST=0.0.0.0
      - REVERB_SERVER_PORT=8080
    volumes:
      - .:/var/www/html
    depends_on:
      - mysql
      - redis
    networks:
      - laravel_site_network

  supervisor:
    build:
      context: .
      dockerfile: ./build/app/Dockerfile
    container_name: laravel_site_supervisor
    restart: unless-stopped
    command: supervisord -n -c /etc/supervisor/supervisord.conf
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
      - TZ=Europe/Moscow
      - MYSQL_DISABLE_SSL_VERIFY=true
      - REVERB_HOST=reverb
      - REVERB_PORT=8080
      - REVERB_SCHEME=http
    volumes:
      - .:/var/www/html
      - ./build/supervisor:/etc/supervisor/conf.d
    depends_on:
      - app
      - mysql
      - redis
      - reverb
    networks:
      - laravel_site_network

  cron:
    build:
      context: .
      dockerfile: ./build/app/Dockerfile
    container_name: laravel_site_cron
    restart: unless-stopped
    command: cron-start
    volumes:
      - .:/var/www/html
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
      - TZ=Europe/Moscow
      - MYSQL_DISABLE_SSL_VERIFY=true
    depends_on:
      - app
      - mysql
      - redis
    networks:
      - laravel_site_network

volumes:
  mysql_laravel_site_data:

networks:
  laravel_site_network:
    driver: bridge

And here are the Dockerfile and nginx settings:

FROM php:8.3-fpm

RUN apt-get update && apt-get install -y \
    git \
    cron \
    curl \
    libcurl4-openssl-dev \
    libpng-dev \
    libzip-dev \
    libonig-dev \
    libxml2-dev \
    libssl-dev \
    unzip \
    supervisor \
    mariadb-client \
    zlib1g-dev

RUN pecl install -o -f redis \
    && rm -rf /tmp/pear \
    && docker-php-ext-enable redis

RUN docker-php-ext-configure zip --with-zip \
    && docker-php-ext-install -j$(nproc) \
    pdo_mysql \
    pcntl \
    zip \
    gd \
    bcmath \
    curl \
    mbstring

RUN docker-php-ext-enable opcache

# Custom PHP settings
COPY ./build/app/php-local.ini /usr/local/etc/php/conf.d/php-local.ini

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Copy crontab
COPY ./build/app/entrypoints/cron-start.sh /usr/local/bin/cron-start
RUN chmod +x /usr/local/bin/cron-start

# Entrypoint for the app container: storage permissions + start php-fpm
COPY ./build/app/entrypoints/app-start.sh /usr/local/bin/app-start
RUN chmod +x /usr/local/bin/app-start

WORKDIR /var/www/html

CMD ["app-start"]

You can put partial overrides of PHP parameters, such as memory_limit or max_upload_size, into /build/php-local.ini.

The /build/nginx/conf.d/app.local.conf config:

server {
    listen 80;
    server_name laravel_site.local;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl default_server;

    server_name laravel_site.local;

    ssl_certificate     /etc/ssl/local/cert.pem;
    ssl_certificate_key /etc/ssl/local/key.pem;

    root /var/www/html/public;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

    index index.php;
    charset utf-8;

    # WebSocket proxy for Laravel Reverb (/app/{key} — pusher protocol)
    location /app/ {
        proxy_pass http://reverb:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

/build/nginx/Dockerfile.local to generate self-signed certificates (runs when the container starts):

FROM nginx:alpine

RUN apk add --no-cache openssl && \
    mkdir -p /etc/ssl/local && \
    openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
      -keyout /etc/ssl/local/key.pem \
      -out /etc/ssl/local/cert.pem \
      -subj "/C=RU/ST=Local/L=Local/O=Dev/CN=laravel_site.local"

and here's /build/entrypoints/app-start.sh

#!/bin/bash
set -e

chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache

exec php-fpm

But the problem is that two projects with this configuration won't start at the same time: the first one's nginx will pull the blanket over to itself and won't let the second one work. Of course, you could move nginx out of the individual projects and make a shared config for all of them. But at first glance that looks too ad hoc and intuitively I don't want to do it. I want some kind of plug'n'play, so that for the next project I can write a docker-compose.yml following certain rules, and it would take its place next to the others by itself. So I gathered my thoughts and set up for these local projects

One shared traefik container.

traefik acts as a reverse proxy. It starts up and distributes incoming traffic among the running local containers. What's more, adding new projects doesn't require changes to the traefik config, it's enough to specify certain labels in the project's docker-compose.yml. And add the local domain to /etc/hosts so it resolves.

docker-compose.yml for traefik:

services:
  traefik:
    image: traefik:v3
    container_name: traefik
    restart: unless-stopped
    command:
      - --api.insecure=true
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=traefik_proxy
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      # Global HTTP → HTTPS redirect
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      # Ignore self-signed certificates on backends
      - --serversTransport.insecureSkipVerify=true
    ports:
      - "80:80"
      - "443:443"
      - "8080:8080"   # Traefik dashboard
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - traefik_proxy

networks:
  traefik_proxy:
    external: true

a script to start the network and the traefik container with a single command:

#!/usr/bin/env bash
set -e

docker network create traefik_proxy 2>/dev/null || echo "Network traefik_proxy already exists"
docker compose up -d
echo "Traefik is running. Dashboard: http://localhost:8080"

A neat piece of software, and I've already run into it in production too, where fifty projects run on a host in their own containers, several per project, and traefik proxies them to the outside world. It even has its own neat little web panel, you can see it in the screenshot at the top.

The changes needed in the project's docker-compose.yml I showed above are minimal:

# the expose section replaces ports in webserver
expose:  
     - "443"
# add the traefik_proxy network and labels for traefik
networks:
      - laravel_site_network
      - traefik_proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.laravel_site-secure.rule=Host(`laravel_site.local`)"
      - "traefik.http.routers.laravel_site-secure.entrypoints=websecure"
      - "traefik.http.routers.laravel_site-secure.tls=true"
      - "traefik.http.services.laravel_site.loadbalancer.server.port=443"
      - "traefik.http.services.laravel_site.loadbalancer.server.scheme=https"
      - "traefik.docker.network=traefik_proxy"

Now I can bring some projects up with docker compose up -d, shut others down with docker compose down, and traefik automatically sees the routes and distributes requests. And the flexibility to reconfigure a project is preserved: you can reconfigure it, add containers to it, and so on.

Life has become a little more convenient.

Discussion

To discuss a post, leave a comment or just get in touch, join the Telegram channel. It is fun there and everyone is welcome!

You can also find me on X (Twitter), VC.ru, Dzen or Tenchat. And if you are on Max, join the Max channel!