NODED.CLOUD/Blog/DDoS Protection for Self-Hosted Apps: A Hands-On Guide

DDoS Protection for Self-Hosted Apps: A Hands-On Guide

22 Oct 2026 · Mario Marin

What the included 100 Gbps of Layer 3/4 filtering does for a self-hosted VPS, and what stays your job: nginx rate limits with a strict login zone, Nextcloud, Vaultwarden and Synapse limits, CrowdSec or fail2ban, and where a CDN stops.

You run Nextcloud, Vaultwarden, a Matrix homeserver and perhaps Immich or Gitea on one VPS, and you want to know what DDoS protection for self-hosted apps actually involves: what your host filters for you, and what you still have to configure yourself. The short version: floods of junk packets are the network's problem, and floods of real-looking HTTP requests are yours.

This post covers what the included network protection does and does not see, nginx rate limiting with a strict login zone, each app's own brute-force limits, CrowdSec and fail2ban, admin panels behind WireGuard, what a CDN can and cannot do, and a checklist for when an attack is under way. Every key was checked against current documentation in October 2026.

What included DDoS protection does for self-hosted apps

Every Noded VPS sits behind 100 Gbps of always-on DDoS protection at Layers 3 and 4, included at no extra cost. There is nothing to enable: no ticket, no DNS change, no agent. UDP floods, DNS and NTP amplification and SYN floods are filtered upstream, before they reach your server's port, and clean traffic keeps flowing. That matters on a VPS: each shape has a network ceiling of 100 to 300 Mbps, so an unfiltered flood would fill the port long before it troubled the CPU. It also covers everything that is not HTTP: a TURN server for Matrix calls, WireGuard, SSH.

What it cannot see is a request that looks like a user. An HTTP flood is a stream of complete connections carrying valid requests; at the network layer it looks like your family syncing photos, and it needs little bandwidth to keep PHP or Python busy on two vCPUs if it targets an uncached search page or a preview generator. Password guessing is quieter still. On Noded, Layer 7 filtering exists only on the 10 Tbps tier at €4,723.95 a month per server, built for businesses under sustained attack. Our attack-types explainer has the theory; the rest of this post is the part that stays your job.

nginx rate limiting: a general zone and a strict login zone

nginx sees every request, so it is the first place to protect self-hosted services from DDoS-style request floods. limit_req caps the request rate per client address and limit_conn caps concurrent connections. In the http block (on Debian and Ubuntu, a file in /etc/nginx/conf.d/):

map $request_method $login_key {
    POST    $binary_remote_addr;
    default "";
}
limit_req_zone  $binary_remote_addr zone=perip:10m rate=20r/s;
limit_req_zone  $login_key          zone=login:10m rate=6r/m;
limit_req_zone  $binary_remote_addr zone=heavy:10m rate=1r/s;
limit_conn_zone $binary_remote_addr zone=conns:10m;
limit_req_status  429;
limit_conn_status 429;

Requests with an empty key are not counted, so the map lets anyone load a login page while only POSTs hit the strict zone: one attempt every ten seconds per address. A 10 MB zone holds about 80,000 addresses on a 64-bit system. Both modules reject with 503 by default; 429 tells clients to back off. Then, in each app's server block:

limit_req  zone=perip burst=50 nodelay;
limit_conn conns 40;

location = /identity/connect/token {      # Vaultwarden login
    limit_req zone=perip burst=50 nodelay;
    limit_req zone=login burst=5 nodelay;
    proxy_pass http://127.0.0.1:8000;      # plus the app's usual proxy headers
}

burst lets a client go over the rate briefly, and nodelay serves those requests at once instead of queueing them. A location with its own limit_req does not inherit the server's, hence the repeated line. Over HTTP/2, every concurrent request counts as a connection, so keep limit_conn generous. Login paths for the strict zone:

AppLogin pathOwn limiter
Nextcloud/login, /index.php/loginBuilt-in brute-force protection
Vaultwarden/identity/connect/token, /adminLOGIN_RATELIMIT_*, ADMIN_RATELIMIT_*
Synapse~ ^/_matrix/client/[^/]+/login$rc_login
Immich/api/auth/loginNone documented
Gitea/user/loginNone; docs give a fail2ban filter

Put expensive endpoints in the heavy zone: Synapse's /search and /publicRooms, Gitea's /archive/ downloads, any search box. Run limit_req_dry_run on for a few days and read the error log first, because sync clients burst. Behind a CDN, restore real client addresses (below) or every visitor shares a few proxy addresses. And publish container ports on 127.0.0.1 only: Docker's published ports skip the host's INPUT rules, so a directly reachable app bypasses nginx.

Brute force: the apps' own limits, then a ban list

Nextcloud brute-force protection

Nextcloud's brute-force protection is built in and on by default. It slows an offending address by up to 25 seconds per attempt, and after 10 attempts in 30 minutes (auth.bruteforce.max-attempts) it answers 429. Behind nginx it must see the real client, or it throttles the proxy, which means everyone. In config.php, with the address nginx connects from:

'trusted_proxies' => ['127.0.0.1'],
'forwarded_for_headers' => ['HTTP_X_FORWARDED_FOR'],

Check an address with occ security:bruteforce:attempts <ip>. Do not switch the protection off; our Nextcloud hosting guide covers sizing.

Vaultwarden rate limit

The Vaultwarden environment template lists the knobs:

LOGIN_RATELIMIT_SECONDS=60
LOGIN_RATELIMIT_MAX_BURST=5
ADMIN_RATELIMIT_SECONDS=300
ADMIN_RATELIMIT_MAX_BURST=3
IP_HEADER=X-Real-IP
SIGNUPS_ALLOWED=false

The defaults are a burst of 10, then one try a minute per address; the block above tightens the burst to 5, and since login and 2FA count together, keep it at 2 or more. The admin page is disabled entirely when ADMIN_TOKEN is unset. In 1.37.4 the X-Real-IP header is trusted only from local addresses (IP_HEADER_TRUSTED_PROXIES=local), which suits nginx on the same host; make nginx send it. More in our Vaultwarden guide.

Synapse rate limiting

listeners:
  - port: 8008
    bind_addresses: ['127.0.0.1']
    type: http
    x_forwarded: true
    resources:
      - names: [client, federation]

The Synapse configuration manual gives rc_login three buckets (per address, per account, failed attempts); the defaults allow a burst of five logins per address, then about one every five and a half minutes. The line that matters is x_forwarded: true: without it every client is 127.0.0.1, and one attacker empties the login bucket for all your users. rc_message limits how fast local users send, rc_registration how fast accounts are created.

CrowdSec with nginx

Rate limits slow an attacker; a ban list stops paying for them, and covers Immich, which documents no login limiter, and Gitea, whose docs point to fail2ban. CrowdSec reads your logs and a bouncer enforces its decisions:

curl -s https://install.crowdsec.net | sudo sh
sudo apt install crowdsec crowdsec-firewall-bouncer-nftables
sudo cscli collections install crowdsecurity/nginx crowdsecurity/nextcloud
sudo systemctl reload crowdsec
sudo cscli metrics

The nginx collection includes generic HTTP probing and brute-force scenarios, plus one that bans an address breaking your limit_req more than five times a minute. The Nextcloud collection also needs nextcloud.log added to the acquisition config. The firewall bouncer drops banned addresses before nginx sees them; crowdsec-nginx-bouncer can show a captcha instead, but needs nginx's Lua module, which Ubuntu 22.04's stock nginx may lack.

fail2ban, the simpler alternative

# /etc/fail2ban/jail.d/web.local
[nginx-limit-req]
enabled  = true
maxretry = 10
findtime = 1m
bantime  = 1h

[recidive]
enabled = true

fail2ban ships the nginx-limit-req filter, so an HTTP flood that keeps tripping your zones gets the address banned at the firewall; recidive bans repeat offenders for a week. Nextcloud's admin manual, the Vaultwarden wiki and Gitea's docs each publish a login filter. If an app container publishes its own port, a ban in the INPUT chain misses it: set the jail's chain to DOCKER-USER or FORWARD, or keep nginx as the only way in.

Shrink the target: WireGuard, caching and a proxy in front

Admin panels only over WireGuard

Nobody can brute-force a page they cannot reach. Put Vaultwarden's /admin, Synapse's /_synapse/admin (its docs advise against exposing it) and any Portainer or database UI behind a WireGuard tunnel:

location /admin {
    allow 10.8.0.0/24;   # WireGuard subnet
    deny  all;
    proxy_pass http://127.0.0.1:8000;
}

Cache what is public

CSS, JavaScript, fonts and icons are identical for every visitor, so serve them from nginx's cache or a CDN with long expiry headers and the app never sees those requests. Never cache anything behind a login: thumbnails, previews and API responses in a shared cache can reach the wrong person.

A reverse proxy or CDN, and where it stops

Noded's own FAQ says it plainly: on a tier without Layer 7, a reverse proxy or CDN in front is the usual answer to HTTP floods. Know its limits. It carries HTTP on a fixed list of ports: Cloudflare proxies HTTPS on 443, 2053, 2083, 2087, 2096 and 8443, so Matrix federation on 8448 does not pass through; delegate it to 443 with /.well-known/matrix/server, as our Matrix homeserver guide describes. TURN, WireGuard and SSH are not HTTP at all; only the network filtering covers them. And read the free-plan terms before proxying media: Cloudflare caps request bodies at 100 MB on Free and requires its paid services for video and other large files, while Immich's own proxy example allows 50,000 MB uploads.

Don't leak the origin IP

A CDN only helps if attackers cannot find the server behind it. DNS-only records on the same address (federation, mail, an SSH name) give it away, as does an nginx default server that answers with your certificate. Add a catch-all server with ssl_reject_handshake on;, allow 443 only from the CDN's published ranges, and restore visitor addresses with set_real_ip_from plus real_ip_header CF-Connecting-IP;. If the address has been public for years, assume it is known; floods aimed straight at it still meet the upstream filtering.

During an attack: a short checklist

  1. Find the layer. Traffic pinned at the shape's ceiling with packet loss is volumetric; busy CPU with normal traffic is an HTTP flood. awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head shows the top sources.
  2. Volumetric and still unreachable? Open a ticket with the IP and the times.
  3. HTTP flood? Lower rate or burst on the path being hit, then nginx -t && systemctl reload nginx.
  4. Ban the sources. cscli decisions add --range 203.0.113.0/24 --duration 4h, or fail2ban-client set nginx-limit-req banip 203.0.113.7.
  5. Close what can wait: sign-ups, public share links, the public room directory.
  6. Afterwards, keep the limits that worked; never disable an app's brute-force protection to quiet complaints.

What this is not

The included tier is Layers 3 and 4 only; Layer 7 filtering exists only on the 10 Tbps tier at €4,723.95 a month per server, so on any smaller tier, HTTP floods and brute force are yours to handle with the steps above. Above 100 Gbps, the first five attacks in a month are still mitigated at no charge; from the sixth, the attacked IP is null-routed until the attack stops or the server moves to a higher tier. Each VPS shape keeps its network ceiling of 100 to 300 Mbps on a shared 1 Gbps node uplink. The VPS is unmanaged: you patch nginx, the apps and CrowdSec, and backups are yours. Rate limiting is not a WAF; it will not save an unpatched app.

How Noded Can Help

For nginx, CrowdSec and a typical set of apps, a VPS II (4 GB, 2 vCPU) or VPS III (8 GB, 2 vCPU) is plenty. A VPS III is €10 a month in Bucharest or €14 in Warsaw or Amsterdam; a VPS II for Vaultwarden plus Gitea is €6 in Bucharest. Both come with 100 Gbps of always-on L3/L4 protection included, nothing to switch on. A paid tier, from €156.45 a month for 500 Gbps, only makes sense for a server hit above 100 Gbps more than five times a month. Details are on the DDoS protection page and the VPS hosting page; for a second opinion, open a ticket.

FAQ

Does DDoS protection cover my self-hosted Nextcloud or Vaultwarden?

Partly. On Noded, 100 Gbps of always-on Layer 3 and 4 protection is included on every VPS and filters UDP floods, amplification and SYN floods before they reach the port. HTTP request floods and password guessing look like normal traffic, so nginx rate limits, each app's brute-force limits and a ban list such as CrowdSec or fail2ban handle those.

How do I rate limit login attempts in nginx?

Create a strict zone in the http block, such as limit_req_zone $login_key zone=login:10m rate=6r/m;, where $login_key is a map returning the client address for POSTs and an empty string otherwise. Apply it in the login location with limit_req zone=login burst=5 nodelay; and set limit_req_status 429.

Should I use CrowdSec or fail2ban for self-hosted apps?

Either works. fail2ban is simpler: enable its nginx-limit-req jail and add the login filters that Nextcloud, Vaultwarden and Gitea document. CrowdSec adds ready-made HTTP scenarios, an nginx collection that bans addresses repeatedly breaking your rate limits, and an optional nginx bouncer with captcha. One of the two is enough.

Can Cloudflare protect a Matrix homeserver?

For client traffic on 443, yes. Federation on 8448 is not among the ports Cloudflare proxies, so delegate it to 443 through /.well-known/matrix/server. TURN for calls runs over UDP and cannot pass through an HTTP proxy at all. Restore real client addresses in nginx, or the homeserver's login limits will treat every user as one visitor.

Does a homelab need Layer 7 DDoS protection?

Rarely. On Noded, Layer 7 filtering is part of the 10 Tbps tier at €4,723.95 a month per server, built for businesses under sustained attack. For a self-hosted VPS, nginx rate limits, the apps' own limits, a ban list and a CDN in front of public HTTP apps cover the request floods a homelab realistically meets.

← All posts

Related services

Run this on NODED.CLOUD.

Keep reading

More from the NOC.

Like the way we run things? Spin up a server in 60 seconds.