Replicating cloud server architecture is always a challenge and creating self signed certificates is cumbersome.

There are tools like mkcert which I highly recommend to get you started but that's only half of the picture.

I have found a solution that is automated.

Getting Started

The code example is available here: github.com/ryanwild/blog-code/tree/main/docker-https-stack

Let's consider the Docker compose file:

services:
  backend:
    build:
      context: .
      dockerfile: ./backend/Dockerfile
    ports:
      - "127.0.0.1:8080:80"
    environment:
      - PYTHONUNBUFFERED=1
    networks:
      dh:

  frontend:
    build:
      context: .
      dockerfile: ./frontend/Dockerfile
    ports:
      - "127.0.0.1:3000:80"
    environment:
      - NODE_ENV=production
      - NODE_EXTRA_CA_CERTS=/cert/root.crt
    user: "${UID}:${GID}"
    volumes:
      - ./cert/:/cert/
    networks:
      dh:

  ingress:
    build:
      context: .
      dockerfile: ./ingress/Dockerfile
    restart: unless-stopped
    ports:
      - "127.0.0.1:80:80"
      - "127.0.0.1:443:443"
    volumes:
      - caddy_data:/data
      - caddy_config:/config
      - ./cert/:/data/caddy/pki/authorities/local/
    networks:
      dh:
        aliases:
          - docker.localhost

volumes:
  db:
  caddy_data:
  caddy_config:

networks:
  dh:
    driver: bridge
    driver_opts:
      enable_ipv4: "true"
      enable_ipv6: "false"
      com.docker.network.bridge.host_binding_ipv4: "127.0.0.1"

Three services, Backend (Python), Frontend (Node.js) and Ingress, a Caddy server that will route incoming traffic.

First build and run the ingress service:

docker compose up ingress --detach

The ingress Caddy server automatically generates TLS certificate.

Once the ingress service is running we need to make sure that the generated file permissions are correct, this is a critical step that ensures the certificates can be shared correctly within the other containers:

sudo chown -R $USER:$USER ./cert

Our next step is to build and run the other services:

docker compose up frontend backend

During build phase our generated certificate is copied to the images and trusted.

For example, the Python backend Dockerfile:

ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
WORKDIR /app
RUN apt-get update && apt-get install -y ca-certificates
COPY ./cert/root.crt /usr/local/share/ca-certificates/root.crt
RUN update-ca-certificates

In some cases you can trust the certificate at runtime, from the Node.js frontend declaration:

frontend:
  build:
    context: .
    dockerfile: ./frontend/Dockerfile
  ports:
    - "127.0.0.1:3000:80"
  environment:
    - NODE_ENV=production
    - NODE_EXTRA_CA_CERTS=/cert/root.crt
  user: "${UID}:${GID}"
  volumes:
    - ./cert/:/cert/
  networks:
    dh:

Adding a volume mount and setting the NODE_EXTRA_CA_CERTS variable is all that is required.

Each service can send and receive https requests without certificate errors.

The ingress service is bound to the https://docker.localhost domain which automatically resolves to 127.0.0.1 in modern browsers so there is no need to edit your systems hosts file.

However we do need to let Docker know how to resolve the domain.

By setting an alias on the ingress service, the alias adds an entry to the internal docker DNS server that resolves to the internal IP address.

networks:
  dh:
    aliases:
      - docker.localhost

When any service within the same Docker network requests https://docker.localhost the domain will resolve to our ingress service and route the request as configured. (See: /ingress/config/Caddyfile)

When the request is consumed over https each service will validate our self signed certificate and parse the request without error.

You need to trust the generated root ca certificate ./cert/root.crt from your browser during development, this will remove all warning messages.

See here: How to trust a self signed certificate in Firefox

Once our services are running we can test by visiting the following url's:

Troubleshooting

If experience issues reading the /cert directory at runtime because the container has a custom user, you can set the correct permissions which will persist across container restarts. For example:

docker compose exec -it --user=root frontend sh -c "chown -R node:node /cert && ls -la /cert"

Conclusion

I have been working with Docker for over a decade.

After Docker was released I switched from Vagrant (remember how slow that was) and have been building Docker environments ever since.

Docker with ".localhost" domains assigned to a Caddy server make testing an https browser environment easy!

Service workers or other browser API's that require https should be tested and built against every day.