← Back to blog

Configuring HTTPS on Linux for Production

tech

This is Part 2 of Securing a Linux Server for Production.

In the last post, we looked at how to secure a Linux server for production by taking some common-sense measures against common vulnerabilities. In this post, I’m going to move forward with setting up HTTPS so that the client communication with the server is encrypted. Additionally, I’m going to set up a reverse-proxy for my server so that all requests can come in through a single entrypoint and get routed to the correct Docker container. As we’ll soon see, this actually solves the problem with Docker overwriting ufw’s iptable routing rules. For both of these things, I’ll be using a cool tool called Traefik.

Traefik is a HTTP reverse-proxy and load balancer written in Go. Let’s go ahead and use it to configure HTTPS, auto-renew SSL Certificates, and proxy incoming requests to our containers, avoiding exporting Docker ports.

There’s a weird little quirk when it comes to using Docker with ufw, and that is that using a Docker EXPOSE <port> directive, or any port mappings in a docker-compose file will actually override the ufw firewall rules. There are many ways to get around this, but I’ll go with configuring Traefik as a reverse proxy. This way I don’t actually need to expose any Docker ports. This will have the added benefit of helping me down the road with HTTPS Certificate renewals via a concept called TLS Termination Proxying - woohoo!

Configuring Traefik As A Reverse Proxy

Since exposing Docker ports overrides ufw’s configs, we can use Traefik to solve this. The goal would be to have just the Traefik service’s port exposed on port 80, which would proxy all incoming traffic to the correct services, without having those services export ports themselves. Traefik handles this by having dynamic service discover through hooking into what it calls “providers”, or anything that has service definitions. This could be things like YAML files with static service definitions, or dynamic service registries like Docker, Kubernetes, Etcd, or Zookeeper. Traefik has ways of hooking into each service definition, which makes its auto discovery feature really powerful.

In this case, we’re using Docker, so we’ll be using the docker provider. Let’s start with a basic docker-compose.yml file to illustrate everything. Since my super secret FastAPI endpoints are very secret, I’ll be using a dummy service image to illustrate this. I’ll call the image myservice:latest, to be generic. Let’s look at an example:

services:
  example_service:
    image: myservice:latest
    command: ["start", "--port", "8000"]
    env_file:
      .env.sample

This will be the base configuration we’ll start with. Just a single service called example_service running an instance of myservice:latest, run with the “start” command on port 8000, and injected with environment variables from a file called .env.sample. Piece of cake. But it’s missing port mappings, and this is intentional - we don’t want to expose ports, since this will expose port 8000 to incoming traffic, but we only want people using our app on port 80. Notice how the start command didn’t actually expose any ports, but rather just ran the program on the port. Now, let’s set up Traefik to proxy requests to myservice:

services:
  example_service:
    image: myservice:latest
    command: ["start", "--port", "8000"]
    env_file:
      .env.sample
    labels:
      - "traefik.enable=true"
      - "traefik.http.services.example_service.loadbalancer.server.port=8000"
      - "traefik.http.routers.example_service.rule=Host(`test.domain.local`)"

  traefik:
    image: traefik:v3.1
    command:
      - "--api.insecure=true"
      - "--providers.docker"
    ports:
      - "80:80"
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

Ok, that looks like a good start! Let’s break this down:

On the example_service, we added a few labels:

  • "traefik.enable=true" - enables Traefik proxying to this service
  • "traefik.http.services.example_service.loadbalancer.server.port=8000" - marks this service’s port as 8000
  • "traefik.http.routers.example_service.rule=Host(`test.domain.localhost`)" - sets a rule to redirect requests to the host (in this case, test.domain.localhost) to this service

And we added a new service called traefik, with the following commands:

  • "--api.insecure=true" - allows access the Web UI
  • "--providers.docker" - this signifies that we’re using the Docker provider

Now that we have this config set, let’s see that commands are proxied correctly. We’ll run docker compose up and then try to hit our API. Hitting test.domain.localhost should show us the response!

One thing to be VERY mindful of is this:

...
volumes:
      - /var/run/docker.sock:/var/run/docker.sock

In order for Traefik to autodiscover Docker services and route requests to them, it needs to hook into the Docker API. Traefik requires access to the Docker socket to get its dynamic configuration, and we’ve enabled this by mounting the Docker socket as a volume into the Traefik container. But this is in fact a security risk. There’s a ton of articles yelling at their readers to avoid this at all costs, because it can give attackers root access to not just the container, but the host machine! That’s… bad. Let’s fix it.

We’ll go ahead and add a new service, called docker-proxy. Any Traefik requests to the Docker socket will be proxied through this service, which can filter out POST or other dangerous requests, and just limit it to GET-style requests.

services:

  example_service:
    image: myservice:latest
    command: ["start", "--port", "8000"]
    env_file:
      .env.sample
    labels:
      - "traefik.enable=true"
      - "traefik.http.services.example_service.loadbalancer.server.port=8000"
      - "traefik.http.routers.example_service.rule=Host(`test.domain.local`)"
    networks:
      - traefik-servicenet

  docker-proxy:
    image: tecnativa/docker-socket-proxy:edge
    networks:
      - docker-proxynet
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      LOG_LEVEL: DEBUG
      CONTAINERS: 1
      SERVICES: 1
      NODES: 1
      NETWORKS: 1
      TASKS: 1
      VERSION: 1

  traefik:
    image: traefik:v3.1
    command:
      - "--api.insecure=true"
      - "--providers.docker"
      - "--providers.docker.exposedByDefault=false"
      - "--providers.docker.endpoint=tcp://docker-proxy:2375"
      - "--providers.docker.network=docker-proxynet"
    depends_on:
      - docker-proxy
    read_only: true
    ports:
      - "80:80"
      - "8080:8080"
    networks:
      - traefik-servicenet
      - docker-proxynet

networks:
  traefik-servicenet:
    name: traefik-servicenet
  docker-proxynet:
    internal: true
    name: docker-proxynet

The image tecnativa/docker-socket-proxy:edge is built on top of HAProxy, which is proxying requests to the socket over tcp. We’re still mounting the /var/run/docker.sock, but in this case, it’s safer because the docker-proxynet network is internal, meaning the docker-proxy container won’t be exposed to the public internet, whereas the traefik container will be. Great! No more mounting sockets to public containers.

Configuring Traefik For HTTPS

Now that we have Traefik set up as a reverse-proxy, and we also set up a docker-proxy to safeguard the Docker socket, it’s time to talk about HTTPS. HTTPS is a protocol built on top of TCP. It works by encrypting the data of the connection over TCP, allowing only the client to decrypt this data, and this is achieved through certificates. Certificates are granted through a Certificate Authority (CA) which assert the authority of the certificate holder. There is a chain-of-trust, such that when the client receives the certificate from the HTTPS server, it will validate the certificate by following its issuers’ certificates up the chain until the last link. If that link is valid, the whole chain is trustworthy, and the connection is good to proceed!

Traefik is nice, because it actually automatically renews its own certificates from Let’s Encrypt, saving us, the users, time from doing this all manually. It’s as simple as adding a few extra configs to Traefik and our service:

services: 
  example_service:
    image: myservice:latest
    command: ["start", "--port", "8000"]
    env_file:
      .env.sample
    labels:
      - "traefik.enable=true"
      - "traefik.http.services.example_service.loadbalancer.server.port=8000"
      - "traefik.http.routers.example_service.rule=Host(`test.domain.local`)"
      - "traefik.http.routers.backend.entrypoints=websecure"
      - "traefik.http.routers.backend.tls.certresolver=myresolver"
      - "traefik.enable=true"
    networks:
      - traefik-servicenet
        
  docker-proxy:
    image: tecnativa/docker-socket-proxy:edge
    networks:
      - docker-proxynet
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      LOG_LEVEL: DEBUG
      CONTAINERS: 1
      SERVICES: 1
      NODES: 1
      NETWORKS: 1
      TASKS: 1
      VERSION: 1

  traefik:
    image: traefik:v3.1
    command:
      - "--api.insecure=true"
      - "--providers.docker"
      - "--providers.docker.exposedByDefault=false"
      - "--providers.docker.endpoint=tcp://docker-proxy:2375"
      - "--providers.docker.network=docker-proxynet"
      - "--entryPoints.web.address=:80"
      - "--entryPoints.websecure.address=:443"
      - "--entryPoints.web.http.redirections.entrypoint.to=websecure"
      - "--entryPoints.web.http.redirections.entrypoint.scheme=https"
      - "--certificatesresolvers.myresolver.acme.tlschallenge=true"
      - "--certificatesresolvers.myresolver.acme.email=ytimen@yuvaltimen.xyz"
      - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
    depends_on:
      - docker-proxy
    read_only: true
    ports:
      - "80:80"
      - "443:443"
      - "8080:8080"
    volumes:
      - letsencrypt:/letsencrypt
    networks:
      - traefik-servicenet
      - docker-proxynet

volumes:
  letsencrypt:

networks:
  traefik-servicenet:
    name: traefik-servicenet
  docker-proxynet:
    name: docker-proxynet
    internal: true

With that, our configs should be done! We want to just verify that everything works by checking the following few items:

  • Requests to https://test.domain.local work
  • Requests to test.domain.local (without specifying protocol, will default to http) redirect to https
  • The dashboard is still active at test.domain.local:8080

And there we have it! A fully functioning HTTPS service, behind a reverse-proxy, accessing the Docker API securely, on a remote host. I think we’ve earned a large piece of tiramisu, don’t you think?

References

  • Traefik’s documentation
  • Dreams of Code’s YouTube video on setting up a production-ready VPS
  • Wollomatic’s repo describing securely accessing Docker sockets
  • Chris Wiegman’s blog post describing setting up Traefik and Docker with HTTPS