Skip to content
Blog

Securing Dokploy: adding a WAF and IPS on the server itself

Trần Quốc BảoTrần Quốc Bảo · Cybersecurity engineer··12 min read
Securing Dokploy: adding a WAF and IPS on the server itself

Photo: Scott Rodgerson / Unsplash

Dokploy is a very convenient self-hosted PaaS, a Heroku/Vercel alternative you run on your own VPS, in the same family as Coolify, CapRover, Easypanel, Dokku and Portainer. Push code and it builds the Docker image, issues Let's Encrypt TLS certificates, and routes traffic through Traefik on Docker Swarm.

The experience is smooth enough that it's easy to skip an important question: between the internet and your application, is anyone standing guard?

On a default install, the answer is close to: nobody. This article walks through three gaps a default Dokploy leaves at the ingress layer, and how to close each one using tools that run on your own server, no paid CDN plan required.

It's written for Dokploy, but every principle carries over to any self-hosted PaaS that stands up its own reverse proxy (Traefik, Nginx Proxy Manager, or plain Nginx) on an internet-facing VPS.

The short version

  • Dokploy uses Traefik as its reverse proxy. Traefik handles TLS and routing very well, but it is not a WAF or an IPS, it does not inspect attack payloads, does not stop brute force, and has no IP reputation.
  • Docker writes iptables rules directly and bypasses UFW: a container port can be exposed to the internet even while the firewall says "deny all".
  • A DNS-only domain sharing an IP with CDN-proxied domains leaks your origin IP, an attacker hits the IP directly with a Host: header and walks straight past the CDN.
  • The fix: a layered defense running on the Dokploy host itself — CrowdSec (engine + AppSec/WAF + bouncers) plus rate limiting and an origin lock.

Three gaps worth checking today

One principle throughout: never conclude anything about security by feel. Each gap below can be tested with a real command, and you should run them against your own system before believing anything.

1. Docker can quietly punch through your firewall

This is not a Dokploy bug, it is how Docker works, and Dokploy's own documentation warns about it:

Docker edits iptables directly, so it bypasses UFW rules. A container running with -p 3000:3000 will expose port 3000 to the internet even when UFW is set to "deny all". That is a false sense of safety.

Dokploy ships a layer of iptables rules in the raw/PREROUTING table that drops access to container IPs arriving on the public interface. That layer is usually enough to keep internal ports hidden, but it is something you should actively verify, not assume. If you run extra containers with -p without understanding this protection, you may be opening ports without realizing it.

How to check, scan from a different machine on the internet, not from the server itself:

# Run from another VPS. List the internal ports you actually have:
# admin panel, databases, cache, metrics exporters...
for p in 3000 9000 5432 6379; do
  curl -s -o /dev/null -w "port $p: %{http_code}\n" --connect-timeout 5 http://YOUR_SERVER_IP:$p/ \
    || echo "port $p: timeout (blocked - good)"
done

What you want to see: every port other than 80/443/SSH times out.

2. The ingress has no WAF

Traefik does not inspect payloads for SQL injection (SQLi), cross-site scripting (XSS), remote code execution (RCE) or path traversal; it has no IP reputation feed to stop bots and scanners; it does nothing to slow down brute force or credential stuffing. A default Dokploy install has no component doing any of that, no WAF (web application firewall), no IDS/IPS. Every domain goes straight from the internet into Traefik and on to your application, through no security filter at all.

The most visible consequence shows up on basic-auth endpoints: by default, you can get the password wrong as many times as you like and still just receive a 401, with no slowdown and no block. An attacker can guess passwords at unlimited speed. Try it on your own system: hit a basic-auth endpoint twenty times in a row and see whether you ever get a 429.

3. Origin leak: when a DNS-only domain betrays your CDN-protected ones

This is the subtlest gap. If you put some domains behind a CDN for protection but leave other domains on DNS-only pointing straight at the origin IP, and all of them share the same IP, then the DNS-only domains are leaking the origin IP for the whole cluster.

An attacker needs two commands:

# 1. Get the origin IP from a DNS-only domain (not behind the CDN)
dig +short status.example.com          # -> 160.x.x.x (origin exposed)

# 2. Hit that IP DIRECTLY, spoofing the Host of a CDN-protected domain
curl -H "Host: shop.example.com" --resolve shop.example.com:443:160.x.x.x \
     https://shop.example.com/

Because Traefik accepts connections from any source, not only from the CDN's IP ranges, that direct hit bypasses the entire CDN layer for those domains. Your edge defense is neutralized by one seemingly harmless DNS record.

How to check: list every domain pointing at the server and mark which are CDN-proxied and which are DNS-only. If the two groups share an IP, you have the gap.

Why defend at the origin instead of leaving it to the CDN

The common reflex is "just proxy everything through the CDN". Three reasons not to stop there:

  1. Free CDN plans are limited. Network-layer DDoS protection, yes; real WAF rules, almost none. Relying on it for application-layer attacks is wishful thinking.
  2. Vendor dependency. Tying your security to a third-party service means that when they change policy, change pricing, or when you need to move infrastructure, your defense moves with them.
  3. The edge can be bypassed, as the third gap just demonstrated. A layer running on the host sees the packets that actually reach your application, and it is always the last one standing.

A CDN is still useful. It just should not be the only layer. This is the same logic behind layered enterprise defense: no single layer is sufficient on its own.

The fix: four layers, all on the Dokploy host

The philosophy is defense in depth, no silver bullet, just layers an attacker has to get through one at a time. From the network up to the application:

Diagram of four defense layers running on the Dokploy host: host firewall, origin lock, rate limiting and CrowdSec

Layer 1 — CrowdSec: the brain of the defense

There are a few ways to get a WAF in front of Traefik: the OWASP Core Rule Set (CRS) via ModSecurity or the Coraza engine (ModSecurity's modern pure-Go successor), or CrowdSec, which bundles WAF and IPS together and adds a community IP reputation network that a static rule set cannot offer.

CrowdSec is an open-source behavioral detection engine (a modern replacement for Fail2Ban). It has four parts with clear responsibilities:

  • Engine — answers "is this IP behaving badly?" by reading access logs plus community IP reputation. This is where decisions are made.
  • AppSec (WAF) — answers "is this request an attack?" by inspecting the payload: SQLi, XSS, RCE, CVE virtual patching. Blocks at the HTTP layer, before the app is touched.
  • firewall-bouncer — enforces blocks at the network layer (iptables/nftables).
  • Traefik plugin bouncer — enforces at the HTTP layer, returning 403.

The subtle point worth remembering:

The engine answers "WHO is bad" (IP reputation, behavior). AppSec answers "WHAT is bad" (attack payloads). Two different questions, two complementary layers. Miss one and you have a blind spot.

The engine runs as a container and reads Traefik's access log:

# acquis.yaml — teach CrowdSec to read Traefik logs
filenames:
  - /var/log/traefik/access.log
labels:
  type: traefik
---
# enable AppSec as its own acquisition source (listening on :7422)
appsec_config: crowdsecurity/appsec-default
labels:
  type: appsec
listen_addr: 0.0.0.0:7422
source: appsec

Install the collections you need, especially virtual patching:

cscli collections install crowdsecurity/traefik
cscli collections install crowdsecurity/appsec-virtual-patching
cscli collections install crowdsecurity/appsec-generic-rules

virtual-patching earns its keep: it virtually patches fresh CVEs (exposed .git/config, Laravel debug mode, Symfony profiler…) right at the proxy, before the real application patch ships.

The bouncer plugin attaches to Traefik as middleware, with both modes on, block bad IPs and forward requests to AppSec for payload inspection:

# dynamic middleware
http:
  middlewares:
    crowdsec:
      plugin:
        crowdsec:
          enabled: true
          crowdsecMode: live
          crowdsecLapiHost: crowdsec:8080
          crowdsecLapiKey: "${CROWDSEC_LAPI_KEY}"
          # enable the WAF: forward requests to AppSec
          crowdsecAppsecEnabled: true
          crowdsecAppsecHost: crowdsec:7422

Layer 2, rate limiting and security headers: cheap, do it first

Before you even stand up CrowdSec, some things can be fixed with a single dynamic config file. Remember the basic-auth endpoint that returns 401 forever? Traefik has the medicine built in:

http:
  middlewares:
    ratelimit:
      rateLimit:
        average: 100
        burst: 50
        period: 1m
    inflight:
      inFlightReq:
        amount: 50
    security-headers:
      headers:
        stsSeconds: 31536000
        stsIncludeSubdomains: true
        stsPreload: true
        contentTypeNosniff: true
        customResponseHeaders:
          Server: ""          # don't advertise "I am Traefik"

Apply it globally to every router via entryPoints.websecure.http.middlewares. Cheap, effective immediately. If you only do one thing today, do this one.

Layer 3, origin lock: close the CDN bypass

For domains genuinely behind a CDN, lock the origin so it only accepts connections from the CDN's IP ranges. Anyone hitting the origin IP directly gets refused by Traefik before touching anything:

http:
  middlewares:
    cdn-only:
      ipAllowList:
        sourceRange:
          # your CDN's IP ranges — Cloudflare shown as an example
          - 173.245.48.0/20
          - 103.21.244.0/22
          - 104.16.0.0/13
          # ... get the full list from the provider's official source
          # and SYNC IT AUTOMATICALLY: hard-coding it and forgetting
          # to update means eventually blocking your own users.

One important note: ipAllowList checks the real TCP connection IP by default and does not read X-Forwarded-For, which is exactly the behavior you want here, because this is the layer that verifies "did this packet really come from the CDN?". Don't add an ipStrategy that reads headers on this middleware, or you open the door to header spoofing.

This is tightening the origin on your own machine, not "letting the CDN protect you". The CDN's IP list is just an allowlist, the decision stays with your Traefik.

Layer 0, the foundation: network, panel, SSH

Last but not least, and straight from Dokploy's official security recommendations:

  • Expose only 80/443/SSH to the internet, and verify it by scanning from outside.
  • Bind the Dokploy admin panel to a private VPN (Tailscale/WireGuard) only. Never expose the admin UI publicly.
  • SSH with keys only, password login disabled, Fail2Ban enabled.

Boring items, and where most real incidents actually happen.

Two traps from real deployments

The solution above only works if you avoid these two traps.

Trap 1: Dokploy manages the Traefik config

Dokploy regenerates the Traefik configuration. If you hand-edit the static config file (traefik.yml), your changes may be overwritten on upgrade or when you change an application through the panel. The survival rule:

Prefer dynamic configuration (files in the dynamic/ directory, or container labels), the kind Dokploy respects and preserves. Only touch static config when you must (for example declaring plugins under experimental.plugins), and re-check it after every Dokploy upgrade.

The rateLimit, headers and ipAllowList middlewares plus the firewall-bouncer are all dynamic config, safe. Only the bouncer plugin needs a static declaration, and that is where to be most careful.

Trap 2: the real client IP, or how to ban the entire world

This is the trap that gets people shooting themselves in the foot. CrowdSec and rate limiting make decisions based on the client IP. But behind a reverse proxy (and behind a CDN), the "client IP" Traefik sees may be the CDN's IP, not the attacker's.

Get it wrong and here is what happens: brute force arrives from everywhere, but Traefik only sees one CDN IP range → CrowdSec bans that range → you have blocked the CDN, which means blocking all of your real users.

Doing it right means separating two groups of domains:

  • Behind the CDN: set forwardedHeaders.trustedIPs to the CDN ranges, then read the real IP from CF-Connecting-IP / X-Forwarded-For.
  • DNS-only: the client IP is the direct connection IP, and you must not trust X-Forwarded-For (it can be spoofed).
Getting the client-IP strategy wrong doesn't just make the defense useless, it turns it into a self-destruct button that blocks your own users. Sort this out before enabling any IP-banning mechanism.

Verification: don't trust, measure

An unverified security change is just hope. The minimum acceptance checklist:

  • Network surface: scan from an external machine — only 80/443/SSH open, everything else times out.
  • Origin lock: curl the origin IP directly with a CDN-backed Host: header → must return 403, not real content.
  • Rate limiting: fire past the threshold → must see 429, not 401 forever.
  • WAF: send a classic SQLi/XSS payload to a test route → AppSec blocks it and it shows up in cscli alerts list.
  • IPS: simulate a scan → the IP gets banned, cscli decisions list has the record, and the next request from that IP is dropped.
  • No self-inflicted wounds: real users through the CDN still get through; cscli decisions list does not contain CDN ranges.

And because the defense layer itself can die, put it under monitoring: alert when a bouncer loses its connection, or when the ban count spikes — a sign that an attack is underway.

When you outgrow a single host

Everything above is written for one Dokploy cluster. Once your organization runs dozens of internal services across multiple clusters, with several teams shipping applications, the problem changes shape: it's no longer "install CrowdSec on the box" but who is accountable at 2am, who re-checks the configuration after every upgrade, and what evidence you can show an auditor that the defense layer is genuinely running.

In Vietnam, that evidence is no longer optional. Once your system falls under information-security level classification, an unguarded ingress is a documentation gap on its own. And if your internet-facing application is a primary business channel, consolidating WAF, DDoS protection, bot mitigation and API security into one coherent layer is exactly the WAAP problem.

What to take away

Dokploy makes self-hosting easy to the point of being dangerous: easy enough that you forget "it runs" and "it's safe" are two different things. Four things to remember:

  1. A reverse proxy is not a firewall. Traefik handles TLS and routing; it does not inspect attacks. The WAF/IPS layer is something you add on purpose.
  2. Verify, don't assume. From "does Docker bypass the firewall?" to "is my origin leaking?", every claim can be tested with one command. Security based on feel is security that doesn't exist yet.
  3. Defend at the origin. CDNs help but can be bypassed. The layer running on your host, seeing the packets that actually reach the app, is the one left standing, and it belongs to you.
  4. Layers, not silver bullets. Firewall + rate limit + IPS + WAF + origin lock: each catches a different kind of blow. The attacker has to get through all of them; you only need one to hold.

If you're running Dokploy (or any self-hosted PaaS with its own reverse proxy) on the public internet, spend fifteen minutes: list every domain, scan your ports from outside, and try attacking yourself. There's a good chance you'll find a door standing open.

Frequently asked questions

Does Dokploy come with a WAF or application firewall?

No. Dokploy uses Traefik as a reverse proxy for TLS and routing, but ships no WAF, IDS/IPS or rate limiting by default. You have to add those layers yourself.

Is Traefik a WAF?

No. Traefik is a reverse proxy and load balancer, not a web application firewall. It does not inspect payloads to block SQL injection, XSS or RCE. For a WAF you need to add CrowdSec AppSec, Coraza/ModSecurity (OWASP CRS), or an equivalent service.

How is CrowdSec different from Fail2Ban?

Fail2Ban reads logs and bans IPs locally using regex rules, good for SSH. CrowdSec does that too but faster (written in Go), supports IPv6, has a community IP reputation network, and adds AppSec as a WAF. A common setup keeps Fail2Ban for SSH and uses CrowdSec for the HTTP layer.

Do I need Cloudflare to secure Dokploy this way?

No. The whole solution runs on the Dokploy server itself, independent of any CDN. A CDN (even a paid plan) is an optional extra layer, not a requirement, and as this article showed, relying on a CDN alone can still be bypassed.

Do these principles apply to Coolify, CapRover or Portainer?

Yes. Any self-hosted PaaS that stands up its own reverse proxy (Traefik or Nginx) on a public VPS shares the same three gaps: Docker bypassing the firewall, an ingress with no WAF, and the risk of leaking the origin IP. The same layered defense model applies.

Related articles

Free resource

Personal Data Protection checklist

Review your business before the law takes effect on 01/01/2026.

Get the checklist