Tony Messias

January 14, 2024

Running Dockerized Apps with HTTPS Locally

I recently needed to run a dockerized application over HTTPS locally. I use Ubuntu, so I don't have the conveniences of Valet or Herd. But don't sweat; we can combine Traefik and mkcert.

Instead of binding each application to local port `:80`, we'll bind a global Traefik container to `:80`, `:443`, and `:8080` (the latter is just where the Traefik dashboard will be available if we want to inspect something). We'll configure the Traefik proxy to use the certificates issued with mkcert.

Let's install mkcert and issue certificates for `*.docker.localhost` domains:

# Install the root CA as a trusted one...
mkcert -install

# Generate the local cert...
mkcert docker.localhost "*.docker.localhost"

This will create two files:
  • The `docker.localhost+1.pem`, which holds the certificate
  • The `docker.localhost+1-key.pem`, which holds our private key

We'll rename them to `cert.pem` and `privkey.pem`, respectively, then move them to a global config folder:

mkdir -p ~/.config/sail-proxy/certs

mv docker.localhost+1.pem ~/.config/sail-proxy/certs/cert.pem
mv docker.localhost+1-key.pem ~/.config/sail-proxy/certs/privkey.pem

Next, we'll create a `~/.config/sail-proxy/tls.yml` file to tell the Traefik container where to look for the certs:

tls:
  stores:
    default:
      defaultCertificate:
        certFile: /etc/traefik/certs/cert.pem
        keyFile: /etc/traefik/certs/privkey.pem
  certificates:
    - certFile: /etc/traefik/certs/cert.pem
      keyFile: /etc/traefik/certs/privkey.pem

These paths referenced in the YAML files will be from inside the Traefik container. We'll map this global config folder to the container soon. Now, let's create a `~/.config/sail-proxy/traefik.yml` file to hold some Traefik configs:

logLevel: DEBUG

api:
  insecure: true
  dashboard: true

entryPoints:
  http:
    address: ":80"
  https:
    address: ":443"

providers:
  file:
    filename: /etc/traefik/tls.yml
  docker:
    endpoint: unix:///var/run/docker.sock
    watch: true
    exposedByDefault: true

For Traefik to serve as a proxy for our app containers, they must be inside the same network, so we'll create a global network, which we should add as an external network to our containers. Then we can spin up the Traefik container, binding the ports and mapping the volumes:

docker network create proxy

docker run -d \
  --restart "unless-stopped" \
  -p 80:80 \
  -p 8080:8080 \
  -p 443:443 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "${HOME}/.config/sail-proxy/:/etc/traefik" \
  --network proxy \
  --name "sail-proxy" \
  traefik:v2.10 \
  --api.insecure=true \
  --providers.docker=true

The proxy container should be running. If you access the dashboard at localhost:8080:

image.png


Now, we need to tweak our application's `docker-compose.yml` file. First, add the external network we just created:

# ...

networks:
    sail:
        driver: bridge
    proxy:
        external: true

Then, we can remove port `:80` from our app's ports; we won't need it. Then, we can configure the labels:

services:
    laravel.test:
        image: 'serversideup/php:8.2-fpm-nginx'
        extra_hosts:
            - 'host.docker.internal:host-gateway'
        ports:
            - '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
        environment:
            SSL_MODE: 'off'
            PUID: '${UID:-1000}'
            PGID: '${GID:-1000}'
        volumes:
            - '.:/var/www/html'
        networks:
            sail:
            proxy:
                aliases:
                    - "turbo-chat.docker.localhost"
        depends_on:
            - mysql
            - soketi
        labels:
            - "traefik.enable=true"
            - "traefik.http.routers.turbo-chat.rule=Host(`turbo-chat.docker.localhost`)"
            - "traefik.http.routers.turbo-chat.tls=true"
            - "traefik.http.services.turbo-chat.loadbalancer.server.port=80"

Notice a few things:
  • We're not binding port `:80`. This container will receive requests via our Traefik proxy
  • We're adding the `proxy` network. We're also adding an alias for this container container inside this network. This is so we can reach this container from other containers running inside this network. When reaching for it from other containers, we gotta do that without HTTPS since that's only served via Traefik
  • We're also setting some labels. That's how Traefik knows it should forward requests to this container. We're setting the Host (our local domain), then we're configuring that it should use TLS, and then we're telling it always to send requests to port `:80` (otherwise, it would send requests to `:443`)

That's all the Docker tweaks we need. But we still need to make one config. Laravel, by default, doesn't trust the forwarded headers from proxies. For local dev, that's normally not an issue, but we now have a proxy running locally, so we need to configure it to trust all proxies, which we can do by setting the proxies property to `*` in the TrustProxies.php middleware:

<?php

namespace App\Http\Middleware;

use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;

class TrustProxies extends Middleware
{
    /**
     * The trusted proxies for this application.
     *
     * @var array<int, string>|string|null
     */
    protected $proxies = '*';

    /**
     * The headers that should be used to detect proxies.
     *
     * @var int
     */
    protected $headers =
        Request::HEADER_X_FORWARDED_FOR |
        Request::HEADER_X_FORWARDED_HOST |
        Request::HEADER_X_FORWARDED_PORT |
        Request::HEADER_X_FORWARDED_PROTO |
        Request::HEADER_X_FORWARDED_AWS_ELB;
}

I'm not sure if Laravel should enforce that locally by default or not. My gut says it shouldn't, but this might cause surprises in production. Don't know.

But that's it! If you open the domain you chose in the browser, you should see a valid certificate!

image.png


I chose `docker.localhost` because that's already handled automatically (because the `*.localhost` domain is always mapped to `127.0.0.1` automatically), so I don't have to run something like dnsmasq to route a configured TLD to localhost or manually edit my `/etc/hosts` file.

I'm mostly documenting this setup for myself. I've compiled this setup into a single `sail-proxy` script, which you can find here if you're curious.

About Tony Messias