High-angle photo of keyboard tiles spelling HTTP on a dark background.

Miguel Á. Padriñán / Unsplash

I’ve been dealing with a frustrating issue in a Symfony application hosted on DigitalOcean App Platform. Since it took me quite a while to find the solution, I thought I’d share it here in case it helps someone else.

By default, every application running on App Platform includes Cloudflare CDN as a reverse proxy. This provides a number of benefits, especially if your application sets appropriate cache-control and etag headers. Cloudflare will use these to cache your application’s responses at the edge, thus reducing load on your servers. So far, so good.

Cloudflare also terminates TLS, which means the traffic is decrypted before being forwarded to DigitalOcean. This presents a problem for my application. It thinks it’s receiving traffic over HTTP rather than HTTPS. As a result, Symfony generates URLs with the http:// scheme rather than https://. This isn’t a major problem, since everything is configured to redirect to https://, but it requires an additional network request, and it’s one of those tiny details that will bother me until I figure out how to fix it.

It’s one of those tiny details that will bother me until I figure out how to fix it.

Meanwhile, Symfony’s documentation isn’t clear about how to solve this. Three pages came up in my search for a solution:

  1. How to Force HTTPS or HTTP for different URLs
  2. Routing: Forcing HTTPS on Generated URLs
  3. How to Configure Symfony to Work behind a Load Balancer or Reverse Proxy

The first isn’t relevant to my problem. It’s about forcing HTTPS or HTTP for different URLs, not about generating HTTPS URLs when behind a reverse proxy. Plus, it has this scary warning at the bottom of the page:

Forcing HTTPS while using a reverse proxy or load balancer requires a proper configuration to avoid infinite redirect loops.

The second one indicates it’s for use when generating URLs in non-HTTP requests (e.g., from CLI tools, cron jobs, email sending tasks, etc.). It also includes the same scary warning:

If your server runs behind a proxy that terminates SSL, make sure to configure Symfony to work behind a proxy.

The configuration for the scheme is only used for non-HTTP requests. The schemes option together with incorrect proxy configuration will lead to a redirect loop.

That warning also links to the third page, which is relevant to my problem. However, anything involving network configuration and CIDR notation has always intimidated me. I was afraid of breaking connectivity to the application, and the only way I could find out whether it worked was to try it in production.

In the end, I needed to make two small changes to my application, neither of which are explicitly documented anywhere:

  1. I replaced x-forwarded-for with the value of do-connecting-ip.

    The note on using do-connecting-ip instead of x-forwarded-for is buried deep in the DigitalOcean documentation, and if you’ve never encountered it, it can come as a surprise. I’ve been around long enough to know this is a common thing that reverse proxies do, so I knew what to look for.

    For Symfony to find the client’s correct IP address, you need to rewrite this header early in the request lifecycle. I did this by adding the following to my index.php above where the Symfony kernel is instantiated:

    // Map DigitalOcean's custom IP header to the standard x-forwarded-for header.
    if (isset($_SERVER['HTTP_DO_CONNECTING_IP'])) {
        $_SERVER['HTTP_X_FORWARDED_FOR'] = $_SERVER['HTTP_DO_CONNECTING_IP'];
    }
    

    I only knew to do this because Symfony shows an example with a different x-forwarded-* header.

  2. I added the following to my config/packages/framework.yaml file:

    framework:
      trusted_proxies: '127.0.0.1,100.64.0.0/10'
      trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
    

    What’s important to note here is the value 100.64.0.0/10 in the list of trusted proxies. I could have used REMOTE_ADDR as suggested in the documentation, but that felt insecure. I tried using PRIVATE_SUBNETS, also suggested in the documentation, but DigitalOcean uses a subnet not found in Symfony’s list of private subnets.

    What’s more, I couldn’t find this subnet listed in the DigitalOcean documentation. However, it’s defined in RFC 6598 as the reserved IPv4 prefix for Shared Address Space. I stumbled on it when I noticed the REMOTE_ADDR value on requests to my application began with 100.127, which wasn’t in Symfony’s list. So I searched to find whether it had any special meaning, and sure enough, it does.

After making these changes, $request->getClientIp() now returns the correct client IP address instead of the IP address of the ingress proxy. I no longer need to use $request->getHeaders()->get('do-connecting-ip') for that value. I am also able to use {{ url('home') }} in my Twig templates instead of the hacky and ugly https://{{ url('home', {}, true) }} I was using before.

Send a Webmention

This post accepts webmentions. If you link to and write about this on your website, you may enter the web address of your article/post in this field. Mentions will appear above. They are heavily moderated, so please be patient while I approve them.

Leave a Comment

Feel free to leave a comment. Comments will appear above. They are heavily moderated, so please be patient while I approve them.

You may use Markdown and some HTML.
Used for avatar lookup only. Never displayed publicly.