Using Traefik to develop locally with Docker (Part 3/x)

This is a continuation of the series explaining how to leverage Traefik to have a flexible setup for local development of your projects using docker. If you read and followed the previous parts, you should have now a valid setup to spin up any new docker project that listen on both HTTP (80) and HTTPS (443). Now let’s put everything together and show how would a new project would look like within this setup. I’m going to do it using standard images to explain all the concepts, on the following post I’ll do the same thing but with a bunch of “tricks” to make all the cumbersome stuff from docker way easier, so don’t fret at the strange commands or processes, I’m not doing it like this on my day to day.

The Project#

In order to demonstrate this, let’s go with a new Laravel app created from scratch. Let’s assume we don’t have (sidenote: No composer, no PHP, no database. Docker and the docker-compose plugin are the only things needed and I'm assumming you have those already if you completed the previous parts of the series. ) . Let’s start by creating a new folder for our project, as $DEITY intended for any software project, initializing a git repo. Then, let’s create the docker-compose.yml, that’ll be our starting point.

$ mkdir my-new-project
$ git init
$ touch docker-compose.yml

Install with composer#

Let’s start from the beginning. We need to create a new Laravel project and, as mention earlier, we are assuming we don’t have anything installed on the computer except Docker, so let’s start our docker-compose.yml by adding the composer image:

# docker-compose.yml
services:
  # Let's include a composer image on its own profile so we can install stuff
  composer_app:
    image: composer:latest
    volumes:
      - ./:/app
    #   - ~/.config/composer:/tmp

Here, we just add the latest available composer image from Docker Hub and mount the current folder (./) into the /app folder from the container. I’ve left the second volume commented out because, in theory, we don’t have a local composer install, (sidenote: Even if you don't have composer installed locally and never plan to, it may make sense to create a folder on your local filesystem to share all the composer config and cache with all the containers so you don't need to download the packages every time. ) , we could mount its config folder to the /tmp folder in the container (which is the value of $COMPOSER_HOME according to the image docs) and share th config and the cache with the container. Anyway, with our docker-compose.yml ready we can now run the following:

docker compose run --user $(id -u):$(id -g) --rm composer_app \
composer create-project laravel/laravel app

The command above will create a new Laravel app on the app folder, we have two options now, either move the docker-compose.yml inside it or move all the newly created files one level up and delete the app folder. In order to do the second you can do:

mv app/{.,}* . && rm -r app

Now that we have a new Laravel app, let’s see what we need to do in order to get access to it.

Web access#

Ok, if you’ve ever worked with PHP, you’ll know there are different options to run a web PHP app, (sidenote: On modern Apache versions, it's recommended to use PHP-FPM with proxy_fcgi but whatever. ) , any webserver and using PHP-FPM as a FastCGI, or even using other modern options like FrankenPHP. Any option should be valid for this setup. Just to pick one, let’s start with Nginx + PHP-FPM.

# The webserver, good ol' nginx
  web_app:
    image: nginx:stable-alpine
    working_dir: /var/www/app
    volumes:
      - ./:/var/www/app
      - ./resources/docker/vhost.conf:/etc/nginx/conf.d/default.conf:ro
    labels:
      - "traefik.enable=true"
      - "traefik.http.middlewares.redirect-2-https.redirectscheme.scheme=https"
      - "traefik.http.routers.insecure-web.entrypoints=web"
      - "traefik.http.routers.insecure-web.rule=Host(`laravel-app.local.example.com`)"
      - "traefik.http.routers.insecure-web.middlewares=redirect-2-https@docker"
      - "traefik.http.routers.web.entrypoints=web-secure"
      - "traefik.http.routers.web.rule=Host(`laravel-app.local.example.com`)"
      - "traefik.http.routers.web.tls=true"
    profiles: [ "web" ]

# PHP-FPM to run our PHP code
  fpm_app:
    image: php:8.3-fpm-alpine
    working_dir: /var/www/app
    volumes:
      - ./:/var/www/app
    #   - ./resources/docker/php.ini:/usr/local/etc/php/conf.d/zz-conf.ini:ro
    #   - ./resources/docker/xdebug.ini:/usr/local/etc/php/conf.d/20-xdebug.ini:ro
    profiles: [ "web" ]

  # Composer from before
  composer_app:
    image: composer:latest
    volumes:
      - ./:/app
    profiles: [ "composer" ]

networks:
  default:
    external: true
    name: gateway

There are some things from the file I’d like to comment on. First, I’m using profiles because they are super handy when you don’t want all your images to run at the same time. Then, when possible, I’m using the alpine version of the images, just because they are smaller and I like musl. This is not necessary and you can run the debian versions or whatever others you prefer. Another thing to notice, I’ve used laravel-app.local.example.com which, obviously is not a valid domain, you should replace it with one you own and for which you’ve generated TLS certs, or use one from backloop.dev. The other new thing from the previous post, is that I’ve included two new labels:

  • traefik.http.middlewares.redirect-2-https.redirectscheme.scheme=https
  • traefik.http.routers.insecure-web.middlewares=redirect-2-https@docker

RedirectScheme is a Traefik middleware that allows you to, well, redirect the request if the request scheme is different from the configured scheme. In this case, requests coming into port 80 (insecure-web router) not using https will get redirected to https.

With that out of the way, let’s go through the file:

  • We have an nginx image with all the labels as explained on the previous post to let traefik know that we want HTTP traffic pointing to the exposed port of the container (80 in this case). We are mounting the current folder (./) into the /var/www/app folder on the container. Then, there’s the ./resources/docker/vhost.conf which we still need to create, (sidenote: The final :ro tells docker to mount it as read-only. This is just a precaution I like to use for files or folders where I want to make sure there is no way they're modified by the container. ) .
  • The PHP-FPM image for version 8.3. Here we are, again, mounting the current folder where docker-compose.yml lives on /var/www/app inside the container. I’ve left, commented out, a couple of references in case we’d want to customize any PHP config, although we won’t be doing that for now.
  • The composer image we used for creating the Laravel project. I’ve only added a profile for it for the same reason.

Ok, we need to create the config file to tell nginx we want to pass all PHP requests to PHP-FPM and serve the project assets otherwise. I like to create a docker folder inside the resources folder to put any config file related to the local dev on there. You can call it whatever you want but make sure whatever folder you use, use the same path on the nginx volume.

# resources/docker/vhost.conf

server {
    listen 80;
    server_name _;
    index index.php index.html;
    root /var/www/app/public;

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

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass fpm_app:9000;
        fastcgi_index index.php;

        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}

A pretty standard config file for PHP, if you need anything extra here like longer timeouts or bigger buffers it’s only a matter of adding whatever nginx directive you need. It’s worth mentioning that we can use fpm_app:9000 as the value passed to fastcgi_pass because docker has its own internal DNS which will resolve the fpm_app (the name we used on the docker-compose.yml for the FPM image) to whatever IP docker assigned it in our system. The document_root points to the public folder within the project, which is the entrypoint for any Laravel app.

Now, to run the whole thing:

docker compose --profile web up -d

If you open your domain you’ll probably see a permission error. That is because FPM is running as www-data on the container but the storage and database folders don’t have writing permissions for it. You can jump into the container to see what id has the www-data group there. First, let’s check the container id that FPM got assigned:

docker ps --filter "name=fpm_app"

Which will give you something like this (your container id will be different):

CONTAINER ID   IMAGE                COMMAND                  CREATED          STATUS          PORTS      NAMES
656239cc397a   php:8.3-fpm-alpine   "docker-php-entrypoi…"   16 minutes ago   Up 16 minutes   9000/tcp   test-app2-fpm_app-1

With that ID, we can get into the running container with:

docker exec -it 656239cc397a sh

We’ll get a shell in the running container, now, we can do:

id www-data

Which will return:

uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)

With that info, we can run, on our local filesystem:

chgrp 82 -R storage && \
chgrp 82 database/database.sqlite && \
chmod g+w -R storage && \
chmod g+w database/database.sqlite

Now www-data will have writing permissions on the container on both, the whole storage folder and the default SQLite database. Reloading your page now should give you the welcome Laravel page.

Summary#

I’ve tried to demonstrate the whole process to get the up running on a computer without any software installed. As you’ve seen, docker and its commands are cumbersome and hard to remember. As demonstrated in this post, this is cool but not really handy. In the following post I’ll try to put together a few tricks and goodies I use to make this process actually nice to work with. I like to think of this post as the basis where all concepts are layed out but don’t be scared by it.