Internal DNS with NPM and PiHole and how to keep Cloudflare Tunnels from breaking

For a long time to access a docker app on my home server, I had to remember which random port I assigned to it. 192.168.1.50:8080 for one app, port 3010 for another, and port 5678 for a third. It worked, but it was messy, hard to remember, and lacked a cohesive security layer.

Recently, I decided it was time to do things the “right” way. I set out to move my Docker containers behind a proper NGINX Proxy Manager (NPM) instance, secure them with a wildcard SSL certificate, and manage it all via Pihole for local DNS. These individual container are now also NOT exposed to my host anymore, NPM manages all the reverse proxying.

I also needed to ensure my Cloudflare Tunnels continued to provide secure remote access.

In this post, I’ll walk you through how I migrated to proper isolated containers and how I moved them behind a centralized “Internal Proxy” network, solved the dreaded “502 Bad Gateway” Cloudflare tunnel errors, and finally ditched the random port numbers for good.

Portainer Network Setup

The first step is to create a network in Portainer. Set your IP ranges as you need them. I called my network “internal_proxy“.

I will put NPM and all the containers I want to go via this reverse proxy inside this network. Cloudflared, which I use for tunneling, will live in 2 networks, more on this later.

Portainer Network Setup


PiHole DNS Setup

I decided to use “internal.leighonline.net” as my domain because I already own the domain “leighonline.net“.

I don’t want to add every single DNS record, so we will use a wildcard so that ANYTHING with the domain “xxxx.internal.leighonline.net” resolves to our docker host.

In order to accomplish this, go to Settings -> All Settings -> Miscellaneous and add this dnsmasq entry. Change the IP address to your IP.

Pihole wildcard DNS


NGINX Proxy Manager and the SSL Cert

Next we can set up NPM (NGINX Proxy Manager) in docker and get an SSL cert for our domain. Note that NPM lives inside my “internal_proxy” network.

services:
  nginxproxymanager:
    container_name: nginxproxymanager
    networks:
      - internal_proxy
    image: 'jc21/nginx-proxy-manager:latest'
    restart: unless-stopped
    ports:
      - '80:80'    # Public HTTP Port
      - '443:443'  # Public HTTPS Port
      - '81:81'    # Admin Web Port
    environment:
      DISABLE_IPV6: 'true'
    volumes:
      - /opt/nginxproxymanager:/data
      - /opt/nginxproxymanager:/etc/letsencrypt

networks:
  internal_proxy:
    external: true # We created this network in portainer manually

Once your container is up and running, go to http://192.168.1.109:81 which is your admin interface and generate a new SSL Certificate and choose DNS Authentication.

Take note of the *. in front of the domain name, this will get you a wildcard certificate which means ANYTHING on internal.leighonline.net can use this certificate.

NPM add new SSL certificate


To generate a Token in Cloudflare, go here. You just need to give that token Zone permissions to the domain name you will be using. (Cloudflare calls a domain name a Zone).

Cloudflare generate Token


Once you have the token, paste it into your NPM window where it asks for the token and wait a few minutes. It will look something like this when the certificate has been generated. Your status will be “Not Used” or the like because no domains are associated with it yet.

NPM SSL Certificate


Back in Portainer

Next we need to change our existing docker stacks to:

  • Be inside this new network we created in step 1. This will allow NPM to forward traffic to these containers using the service name.
  • Remove the “ports” directive so these containers are NOT exposed to the host anymore. NPM will handle all connections to these apps now. Our containers are now isolated.

Here is my stirlingpdf stack as an example:

services:
  stirlingpdf:
    image: stirlingtools/stirling-pdf:latest
    container_name: stirlingpdf_app
    networks:
      - internal_proxy
    # ports:
    #  - '8040:8080'
    expose: # Just informative
      - 8080
    volumes:
      - /opt/stirlingpdf/trainingData:/usr/share/tessdata # Required for extra OCR languages
      - /opt/stirlingpdf/extraConfigs:/configs
      - /opt/stirlingpdf/customFiles:/customFiles/
      - /opt/stirlingpdf/logs:/logs/
      - /opt/stirlingpdf/pipeline:/pipeline/
    environment:
      - DOCKER_ENABLE_SECURITY=true
      - SECURITY_ENABLE_LOGIN=true
      - LANGS=en_GB

networks:
  internal_proxy:
    external: true

As you can see I commented out ports and added this stack into my internal_network. This container is now not exposed to the host anymore. NPM will handle connections to it.

Back in NPM

Now lets add a new Proxy Host:

  • Set your preferred domain name on “internal.leighonline.net”. I chose “stirlingpdf.internal.leighonline.net”.
  • The Forward Hostname/IP is the docker compose service name we defined up top.
  • The port is the INTERNAL port that this container is listening on. You can find this on the applications docker or github page, or by looking at the docker file.
NPM new Proxy Host

Under the SSL Tab, select your newly created SSL certificate from the dropdown and enable these 2 options:

NPM new Proxy Host SSL config


Testing Time

Type this command:

nslookup stirlingpdf.internal.leighonline.net

You will see it resolves via your Pihole:

nslookup dns pihole test

You will also see that ANYTHING under internal.leighonline.net will resolve, because we told Pihole to do so a few sections up when we added that dnsmasq config:

nslookup dns pihole test anything will resolve


Cloudflare Tunnel Setup

We need to make sure our Cloudflare tunnels still work. Change your cloudflared compose (stack) file to be in both the internal network and in its default network.

By adding it to both, cloudflared can get to things outside of our new network as well, like anything else on your network such as Plex, your Sonic Pad, etc.

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel run --token eyJhxxxxxxxxxxx
    networks:
      - default          # This keeps your LAN access (Sonic Pad, etc.)
      - internal_proxy   # This adds access to your new network
      
networks:
  internal_proxy:
    external: true

Look at the orange block in the image below. Instead of pointing your applications to the container’s IP and port, you now just give it the NPM service name. (the service name is defined in your compose file)

Cloudflare tunnel service name


For each app though, we need to add host headers. Because each app now just points to our NPM, our NPM needs to know which host to pass the request and the host headers take care of this.

So add “stirlingpdf.internal.leighonline.net” in both the TLS Origin Server Header and the HTTP Host Header fields. This has to do with SNI and all that.

Cloudflare tunnel application config


necrolingus

Tech enthusiast and home labber