Securing Coolify-managed applications behind a WireGuard VPN

Securing Coolify-managed applications behind a WireGuard VPN

My Self-Hosting journey started just this year (2024) when I discovered Coolify through a colleague. Self-Hosting your own apps is super satisfying—until you realize the entire internet might be able to poke at them if you’re not careful. That’s why I decided to lock everything behind a VPN. I chose WireGuard for its speed and simplicity, Pi-hole for local DNS magic, and Coolify as my self-hosted PaaS. I decided to document this story, since I couldn't find much documentation online. Hopefully, it might help others in their self-hosting journey. In this post, I’ll walk through the journey of how I got them all working together so that my private domains are only accessible when connected to the VPN.

The Vision

I wanted to:

  1. Self-host multiple apps (like Pi-hole, analytics dashboards, or the Coolify UI) in Docker.
  2. Hide them from the open internet, only letting me access them via a VPN.
  3. Use local DNS so that apps live on domains like pihole.my-domain.com (instead of having to use IP:port combos).
  4. Maintain some public-facing services, but keep the “admin stuff” private.

The Ingredients

WireGuard

A modern, lean, and super-fast VPN. It creates that “tunnel” so only I can reach those private apps.

Coolify

A self-hosted platform that makes managing Docker deployments a breeze. You define an app, give it a domain, and it sets up proxies (Traefik or Caddy) automatically.

Pi-hole

Known primarily for ad-blocking on local networks, Pi-hole also provides a handy local DNS feature, letting me define “domain → IP” mappings.

Pi-hole + Local DNS = Private Domain

Instead of using public DNS records on Cloudflare or Route53 for sensitive internal domains, I can remove them from the public zone and store them in Pi-hole. This way, only devices connected to the VPN will ever see them resolve.

Overview of the Final Setup

  1. Master Node: Runs Coolify, Pi-hole, and a few “admin” apps.
  2. WireGuard: Also on the same server, exposing a UDP port (often 51820).
  3. Local DNS in Pi-hole:
    • myapp.my-domain.com → 10.0.8.1 (WireGuard or Docker network IP, whichever the service is listening on)
    • wg.my-domain.com → 10.8.0.1 (my VPN interface)
  4. Coolify (with Traefik or Caddy under the hood) automatically sets up HTTP→HTTPS, domain routing, etc., once I specify the domain in its UI.

Step-by-Step

1. Deploy WireGuard

I started by installing WireGuard. My core goals:

  • Full-tunnel access: AllowedIPs=0.0.0.0/0 in the client config.
  • Or partial tunnel if needed, but I went full because I wanted all DNS queries to funnel through Pi-hole.

Server-Side:

I used Coolify’s built-in service template for “WireGuard Easy.” In the UI, I gave it a domain (wg.my-domain.com:8000 originally) so I could manage the container’s web UI.

I made some adjustments to the docker-compose:

services:
  wg-easy:
    image: 'ghcr.io/wg-easy/wg-easy:latest'
    environment:
      - SERVICE_FQDN_WIREGUARDEASY_8000
      - 'WG_HOST=${SERVICE_FQDN_WIREGUARDEASY}'
      - 'LANG=${LANG:-en}'
      - PORT=8000
      - WG_PORT=51820
      - WG_DEFAULT_DNS=10.0.8.1
      - 'WG_ALLOWED_IPS=0.0.0.0/0, ::/0'
      - WG_PERSISTENT_KEEPALIVE=25
      - PASSWORD_HASH='${PASSWORD_HASH}'
      - UI_TRAFFIC_STATS=true
    volumes:
      - 'wg-easy:/etc/wireguard'
    ports:
      - '51820:51820/udp'
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    sysctls:
      - net.ipv4.conf.all.src_valid_mark=1
      - net.ipv4.ip_forward=1

WireGuard Easy uses the environment variables starting with WG_ to populate the .conf files which can be exported from the UI. You'll need this later to configure the client.

In the UI, after creating the client, you can just download the .conf file that you can use locally.

Client-Side:

$ sudo apt-get install wireguard

Then, add the .conf file to /etc/wireguard/[some-name].conf. You can then easily connect with wg-quick up [some-name]. One way to check if you are connected, is to check whether you ip address now corresponds to your server's IP: icanhazip.com

2. Pi-hole for Local DNS

Setting up Pi-Hole wasn't that straightforward for me. I struggled between trying to set it up in "host" mode versus in bridge mode. (I never actually managed to make it working in host mode). The main thing to understand here, is whether you want system-wide DNS pointing to Pi-Hole, you should use host mode. However, I ended up having some conflicts and none of my DNS queries would resolve though Pi-Hole.

Once Pi-hole was up (also deployed via Coolify), I turned on its Local DNS feature:

  • pihole.my-domain.com → 10.0.8.1
  • coolify.my-domain.com → 10.0.8.1
  • uptimekuma.my-domain.com → 10.0.8.1
  • etc.

But for WireGuard’s domain, I mapped:

  • wireguard.my-domain.com → 10.8.0.1

Why the difference?

  • The “other apps” were listening on Docker’s “main network,” which I determined was accessible at 10.0.8.1 from inside the VPN.
  • WireGuard itself was listening on its own interface, 10.8.0.1.

3 Tying it Together in Coolify

In Coolify, you can define each service with a “Domain” or “FQDN.” For example:

  • Pi-hole: I set the domain to https://pihole.my-domain.com:8081. Then in Pi-hole, I internally told it “Local DNS for pihole.my-domain.com is 10.0.8.1.” This ensures that once I connect to WireGuard, Pi-hole answers queries and the domain resolves to the container. Now, I can visit https://pihole.my-domain.com from my laptop—no port needed.
  • WireGuard: Initially set to wireguard.my-domain.com:8000. I eventually changed that domain in the Coolify UI to remove the :8000 so I could just do https://wg.my-domain.com. Under the hood, Traefik (or Caddy) is reverse-proxying 443 to container port 8000.

At first, some apps weren’t accessible at https://whatever.my-domain.com—I had to add the “:port.” The fix was to remove the port from the domain in Coolify so it auto-manages the proxy correctly.

4 Testing the Magic

  • Connect to WireGuard from my laptop.
  • Pi-hole logs show queries from my laptop.
  • Try https://coolify.my-domain.com. Bingo, it loads up the Coolify dashboard.
  • Try https://wg.my-domain.com. That takes me to the WG-Easy interface.
  • Public DNS queries for these subdomains fail, as expected, because there are no records in the public zone. Private only!

Notes & Decisions

1. Why Full Tunnel Instead of Split?

I found that using a partial tunnel with AllowedIPs=10.8.0.0/24 sometimes made DNS break if Pi-hole was on the VPN side but traffic to the actual websites was on the public side. Instead, full tunnel made sure everything was consistent.

2. Why Pi-hole Over Plain Docker + Hosts?

Pi-hole simplifies the local DNS management. If I want a new private app, I just go to Pi-hole → Local DNS → “my-newapp.my-domain.com → 10.0.8.1.” Boom, done.

3. Removing Public DNS Records

To fully “hide” these domains, I removed (or never created) them in Cloudflare. If I needed a Let’s Encrypt certificate, I used either a DNS challenge or temporarily made them public and blocked them again. But for pure internal usage, self-signed or DNS-challenge works fine.

Common Pitfalls

  1. Forgetting to disable systemd-resolved or free up port 53 if you’re running Pi-hole on the same host.
  2. Forgetting to remove the domain from public DNS. If it still resolves publicly, your security is only partial.
  3. Split Tunnel with Pi-hole DNS can lead to weird routing issues if DNS is on the VPN but websites are not.
  4. Wrong Docker IP in Local DNS. If you use 10.0.8.1 but the container isn’t actually reachable on that IP, you’ll get timeouts.

Conclusion

After a bit of trial, error, and rummaging through logs, everything works:

  • I connect my laptop or phone to WireGuard.
  • Pi-hole’s local DNS returns private IPs for private domains.
  • Coolify + reverse proxy ensures domain-based routing, so I don’t have to memorize ports.
  • The public has no clue these apps exist, and I can safely tinker with them behind my VPN.

Final Thoughts

As I've mentioned in the beginning, I'm quite new to self-hosting and networking. If you have any feedback, I'll be happy to hear it!