Right now, if you self-host apps, you probably reach them at something like http://192.168.1.50:8096 — an IP address and a port. That works on your LAN, but it’s unencrypted, impossible to remember, and useless from outside your house. This guide takes you start to finish through running SWAG (Secure Web Application Gateway, by linuxserver.io) as a reverse proxy, using Cloudflare DNS validation to get free wildcard HTTPS certificates from Let’s Encrypt. It’s written for someone who has never done this before — every step explains what you’re doing and why.
- What you’re building
- What you need before starting
- Point your domain at Cloudflare
- Create a Cloudflare API token
- Create your DNS records
- Forward ports on your router
- Create a Docker network
- Write the SWAG docker-compose file
- First run (staging test)
- Add the Cloudflare credentials
- Get the real certificate
- Proxy your first app
- Proxy an app with no sample config
- Test everything
- Maintenance, hardening, troubleshooting & glossary
What you’re building
A reverse proxy sits in front of all your apps. The outside world talks only to the proxy on port 443 (HTTPS). The proxy looks at the hostname requested (jellyfin.example.com vs books.example.com) and quietly forwards the request to the right container internally.
┌──────────────── Your server ────────────────┐
│ │
Internet ──443──▶ SWAG ──▶ jellyfin.example.com ──▶ jellyfin:8096
│ ──▶ books.example.com ──▶ calibre:8083
│ ──▶ vault.example.com ──▶ vaultwarden:80
└─────────────────────────────────────────────┘
SWAG is a single Docker container that bundles Nginx (the actual reverse proxy), Certbot (automatically gets and renews free Let’s Encrypt SSL certificates), Fail2ban (bans IPs that repeatedly fail logins), and pre-written config samples for around 200 popular self-hosted apps.
Why Cloudflare DNS validation specifically?
To issue a certificate, Let’s Encrypt must verify you actually control the domain. There are two ways to prove it:
| Method | How it proves ownership | Downsides |
|---|---|---|
| HTTP validation | Let’s Encrypt connects to your server on port 80 and reads a file | Requires port 80 open to the world; cannot issue wildcard certs |
| DNS validation | Certbot creates a temporary TXT record in your DNS; Let’s Encrypt reads it | Requires an API token for your DNS provider |
DNS validation is the better fit for a homelab: you get a wildcard cert (*.example.com) so one certificate covers every subdomain you’ll ever create; port 80 doesn’t need to be open (some ISPs block it); and it works even if your server isn’t reachable from the internet at all, so LAN-only setups still get valid HTTPS. Cloudflare is the easiest DNS provider for this — free, fast API, and Certbot has a first-class plugin for it.
What you need before starting
- A domain name you own — roughly $10–15/year from Cloudflare Registrar, Namecheap, Porkbun, etc. You can’t use a free dynamic-DNS hostname like
something.duckdns.orgfor this method, because you don’t control its nameservers. (If you don’t own a domain, DuckDNS has its own SWAG plugin — but the rest of this guide assumes a real domain.) - A free Cloudflare account.
- A Linux server with Docker and Docker Compose — Ubuntu/Debian, OMV, Unraid, Synology, a Pi, anything.
- Admin access to your router (to forward a port).
- Your server’s static LAN IP. Set a DHCP reservation in your router so it never changes. Find it with
ip a. - At least one app already running in Docker to point the proxy at — Jellyfin, Vaultwarden, Nextcloud, anything.
- Comfort with a terminal, or a file manager on the server.
Throughout this guide, replace these placeholders with your own values:
| Placeholder | Means | Example |
|---|---|---|
example.com |
Your domain | mydomain.net |
you@example.com |
Your email | me@gmail.com |
/opt/appdata |
Where you keep Docker config folders | /srv/dev-disk-by-uuid-xxx/appdata on OMV |
192.168.1.50 |
Your server’s LAN IP | whatever ip a shows |
America/Toronto |
Your timezone | see the TZ database list |
Step 1 — Point your domain at Cloudflare
Cloudflare has to be the authoritative DNS for your domain, otherwise Certbot’s API calls will write TXT records nobody reads. Skip this step if you bought your domain from Cloudflare Registrar — it’s already done.
- Log into the Cloudflare dashboard.
- Click Add a domain (or + Add → Existing domain).
- Type your domain (
example.com— no www, no https://) and continue. - Choose the Free plan. It does everything you need here.
- Cloudflare scans your existing DNS records and shows them. Review the list — if you have email on this domain, make sure the MX and TXT (SPF/DKIM/DMARC) records came across. Missing mail records is the single most common way people break their email doing this.
- Cloudflare gives you two nameservers, something like
dara.ns.cloudflare.comandrick.ns.cloudflare.com. - Go to wherever you bought the domain (Namecheap, GoDaddy, HostPapa…), find the nameserver / DNS settings, choose custom nameservers, delete what’s there, and enter Cloudflare’s two.
- Save, then wait. Propagation is usually 10–60 minutes but the registrar may say up to 24 hours. Cloudflare emails you when the domain goes Active.
Do not continue until the domain shows “Active” in Cloudflare. Nothing below will work otherwise.
Step 2 — Create a Cloudflare API token
This token lets Certbot create and delete the temporary TXT records that prove you own the domain. Use a scoped API Token, not the Global API Key. The Global Key can do anything to your entire account; a scoped token can only touch DNS on one domain, so if it leaks the damage is limited.
- In the Cloudflare dashboard, click your profile icon (top right) → My Profile.
- Left sidebar → API Tokens.
- Click Create Token.
- Find the template Edit zone DNS and click Use template.
- Configure it:
- Permissions should already read Zone — DNS — Edit. Leave it.
- Click + Add more and add Zone — Zone — Read (Certbot needs this to look up your zone ID).
- Zone Resources: Include — Specific zone — example.com. Do not leave it on “All zones”.
- Client IP Address Filtering: leave blank unless you have a static WAN IP and know what you’re doing.
- TTL: leave as-is.
- Continue to summary → Create Token.
- Copy the token now — it is shown exactly once. Paste it somewhere safe temporarily (a password manager, not a text file on your desktop). It looks like
1Ab2Cd3Ef4Gh5Ij6Kl7Mn8Op9Qr0St1Uv2Wx3Yz.
Step 3 — Create your DNS records
Still in Cloudflare, open your domain → DNS → Records. First, find your public IP by running this on your server:
curl -4 ifconfig.me
That’s your home’s public IPv4 address (e.g. 203.0.113.7). Now add two records:
| Field | Record 1 — root domain | Record 2 — wildcard |
|---|---|---|
| Type | A | A |
| Name | @ (the bare domain) |
* |
| IPv4 address | your public IP | your public IP |
| Proxy status | DNS only (grey cloud) | DNS only (grey cloud) |
| TTL | Auto | Auto |
The * wildcard means any subdomain (jellyfin.example.com, books.example.com, anything) resolves to your home IP — so you never have to add another DNS record when you add a new service.
Orange cloud vs grey cloud — read this
That cloud toggle matters. Grey cloud (DNS only) means Cloudflare just answers the DNS lookup and visitors connect straight to your home IP (your public IP is exposed). Orange cloud (Proxied) routes traffic through Cloudflare’s network, hiding your IP and adding DDoS protection and caching. Proxying sounds strictly better, but on the free plan it comes with real limits:
- Only certain ports pass through (80, 443, 8443, 2053, 2083, 2087, 2096 for HTTPS).
- 100 MB max upload size per request — this breaks Nextcloud file uploads.
- Cloudflare’s Terms of Service §2.8 prohibit serving large amounts of video or non-HTML content through the free proxy. Running Jellyfin behind an orange cloud is a ToS violation, and people do get flagged.
Recommended: start with grey cloud for everything so you can confirm the setup works end to end. Later you might switch low-bandwidth services (a dashboard, a wiki) to orange and keep media/file services grey. If you want your IP hidden and to stream media, the right answer is Cloudflare Tunnel, not the proxy toggle — that’s a separate setup.
If your ISP gives you a dynamic IP (most residential connections), your public IP will change eventually and these A records will go stale. Fix it with a DDNS updater container such as oznu/cloudflare-ddns or favonia/cloudflare-ddns, which keeps the records pointed at your current IP automatically — set that up after you finish this guide. And if you’re behind CGNAT (common with Starlink and some fibre/mobile ISPs), your “public” IP isn’t really yours and port forwarding will never work — compare curl -4 ifconfig.me to the WAN IP in your router; if they differ you’ll need Cloudflare Tunnel or a VPS instead. DNS validation will still get you certificates just fine.
Step 4 — Forward ports on your router
You need the outside world to reach your server on port 443 (HTTPS). Log into your router (usually 192.168.1.1; UniFi users go to Settings → Routing & Firewall → Port Forwarding) and create a rule:
| Field | Value |
|---|---|
| Name | SWAG HTTPS |
| Protocol | TCP |
| External / WAN port | 443 |
| Internal IP | 192.168.1.50 (your server) |
| Internal port | 443 |
Optionally add a second rule for port 80 (TCP, external 80 → internal 192.168.1.50:80). Port 80 is not needed for certificates — that’s the whole point of DNS validation. It’s only so someone typing http://jellyfin.example.com gets automatically redirected to HTTPS. If your ISP blocks port 80, just skip it; everything still works, users just have to type https://.
Security note: you are opening a door into your network. That’s exactly what SWAG is designed to guard, but only expose services you’re comfortable having on the public internet, and put a strong password on every one of them.
Step 5 — Create a Docker network
For SWAG to forward traffic to your other containers, it needs to reach them. The clean way is a shared user-defined Docker network, where containers talk to each other by container name instead of IP:
docker network create proxynet
Every container SWAG proxies to must be attached to proxynet (you’ll do that in Step 10). You could use the host IP instead (192.168.1.50:8096), and it works — but container names are stable, don’t break when your LAN IP changes, and let you stop publishing app ports to the LAN at all, which is more secure.
Step 6 — Write the SWAG docker-compose file
Docker containers need to know which user account to run as so file permissions work. Find your PUID and PGID on the server:
id $(whoami)
Output looks like uid=1000(youruser) gid=1000(youruser) groups=.... Note the two numbers — usually 1000 and 1000. Then create the folder and file:
mkdir -p /opt/appdata/swag cd /opt/appdata/swag nano docker-compose.yml
Paste this in, then edit the marked lines:
services:
swag:
image: lscr.io/linuxserver/swag:latest
container_name: swag
cap_add:
- NET_ADMIN # required for fail2ban to write iptables rules
environment:
- PUID=1000 # <-- from `id`
- PGID=1000 # <-- from `id`
- TZ=America/Toronto # <-- your timezone
- URL=example.com # <-- your domain, no https://, no www
- VALIDATION=dns # use DNS validation
- DNSPLUGIN=cloudflare # ...via Cloudflare
- SUBDOMAINS=wildcard # request a *.example.com cert
- ONLY_SUBDOMAINS=false # also cover the bare example.com
- EMAIL=you@example.com # <-- expiry warnings go here
- STAGING=true # <-- TEST MODE. We turn this off later.
volumes:
- /opt/appdata/swag/config:/config # <-- your appdata path
ports:
- 443:443
- 80:80
restart: unless-stopped
networks:
default:
name: proxynet
external: true
Save with Ctrl+O, Enter, then exit with Ctrl+X. What each setting does:
| Variable | Meaning |
|---|---|
PUID / PGID |
User/group the container runs as, so files it writes are owned by you |
TZ |
Timezone — affects log timestamps and cron timing |
URL |
Your base domain. No www., no https://, no trailing slash. |
VALIDATION=dns |
Prove domain ownership via DNS TXT records |
DNSPLUGIN=cloudflare |
Which DNS provider’s API to use |
SUBDOMAINS=wildcard |
Request *.example.com instead of listing subdomains individually |
ONLY_SUBDOMAINS=false |
Include the bare example.com in the cert as well |
EMAIL |
Let’s Encrypt sends expiry warnings here |
STAGING=true |
Use Let’s Encrypt’s test servers — see below |
cap_add: NET_ADMIN |
Lets fail2ban modify firewall rules inside the container |
networks: proxynet |
Attaches SWAG to the shared network from Step 5 |
About STAGING=true: Let’s Encrypt enforces hard rate limits — 5 failed validations per hour and 50 certificates per domain per week. Fumble your API token five times and you’re locked out for an hour; repeated mistakes can lock you out for days. The staging server has far looser limits. Its certificates aren’t trusted by browsers (you’ll get a warning) — that’s fine, we’re only checking the plumbing works. Once it succeeds we flip to production.
Step 7 — First run (staging test)
Start the container:
docker compose up -d
(On older systems the command is docker-compose up -d with a hyphen.) First run pulls the image, then generates the /config folder structure. Watch it:
docker logs -f swag
You’ll see it initialise, then fail with something like Cloudflare credentials not found, please add them to /config/dns-conf/cloudflare.ini. That’s expected — the container had to run once to create the config folder. Press Ctrl+C to stop following the logs (this does not stop the container).
Step 8 — Add the Cloudflare credentials
Open the file SWAG just created:
nano /opt/appdata/swag/config/dns-conf/cloudflare.ini
It ships with commented instructions and two options — the legacy email + Global API Key, and the modern API token. Use the token. Make the file’s uncommented content look exactly like this:
# Cloudflare API token dns_cloudflare_api_token = 1Ab2Cd3Ef4Gh5Ij6Kl7Mn8Op9Qr0St1Uv2Wx3Yz
Make sure any dns_cloudflare_email and dns_cloudflare_api_key lines are commented out with # or deleted — if both methods are present, Certbot errors out. Save and exit, then lock down the permissions (this file is a key to your DNS):
chmod 600 /opt/appdata/swag/config/dns-conf/cloudflare.ini
Restart and watch:
docker restart swag docker logs -f swag
Success looks like Successfully received certificate. … Server ready. If you see errors, jump to Troubleshooting — most likely the token is wrong or the domain isn’t Active in Cloudflare yet.
Step 9 — Get the real certificate
Staging worked, so now get a browser-trusted certificate.
- Edit your compose file:
nano /opt/appdata/swag/docker-compose.ymland changeSTAGING=truetoSTAGING=false. - Delete the staging certificates — SWAG won’t replace them on its own, it sees a valid cert and skips renewal:
rm -rf /opt/appdata/swag/config/etc/letsencrypt
- Recreate the container:
cd /opt/appdata/swag docker compose up -d --force-recreate docker logs -f swag
Wait for Successfully received certificate. followed by Server ready. The whole process takes a minute or two — Certbot writes the TXT record, then deliberately waits for DNS to propagate before asking Let’s Encrypt to check. You now have a wildcard certificate at /config/etc/letsencrypt/live/example.com/, valid for 90 days and renewed automatically.
Confirm it: visit https://example.com. You should get SWAG’s default welcome page with a valid padlock, no warnings. If you see the padlock, the hard part is done.
Step 10 — Proxy your first app
Let’s use Jellyfin as the example; the same pattern works for any app.
10a. Put the app on the proxy network
Edit the app’s own compose file and attach it to proxynet:
services:
jellyfin:
image: lscr.io/linuxserver/jellyfin:latest
container_name: jellyfin # <-- this name matters
# ... your existing config ...
networks:
- proxynet
networks:
proxynet:
external: true
Then docker compose up -d. Verify both containers can see each other with docker network inspect proxynet | grep -A2 Name — you should see both swag and jellyfin listed. Remember: container name = hostname. SWAG will reach Jellyfin at http://jellyfin:8096; if your container is named jellyfin-server, the config must say jellyfin-server.
10b. Enable the sample config
SWAG ships ready-made configs for hundreds of apps in the proxy-confs folder with a .sample suffix (which nginx ignores). Activate one by copying it without the suffix:
cd /opt/appdata/swag/config/nginx/proxy-confs cp jellyfin.subdomain.conf.sample jellyfin.subdomain.conf
Browse what’s available with ls in that folder — you’ll find nextcloud, vaultwarden, sonarr, grafana, homeassistant, and many more. Prefer .subdomain.conf over .subfolder.conf: subdomain gives you jellyfin.example.com, subfolder gives you example.com/jellyfin and frequently breaks apps that assume they live at the site root. Since you have a wildcard cert, subdomains cost you nothing.
10c. Check the config’s assumptions
Open it with nano jellyfin.subdomain.conf. Near the top of the location block you’ll see:
set $upstream_app jellyfin; set $upstream_port 8096; set $upstream_proto http;
$upstream_app is the container name (or an IP), $upstream_port is the app’s internal port (the right-hand number in 8096:8096), and $upstream_proto is http or https. The defaults are correct if you used standard names and ports; if your container is named something else, change $upstream_app to match. Also note the top line server_name jellyfin.*; — that’s what makes the config respond to jellyfin.example.com. Change jellyfin there if you want a different subdomain, e.g. media.*.
10d. Reload
docker restart swag
Then visit https://jellyfin.example.com — you should get Jellyfin, over HTTPS, with a valid certificate. Repeat 10a–10d for every app you want to expose.
Step 11 — Proxy an app with no sample config
If your app isn’t in the samples folder, write your own. Create a file ending in .conf in the proxy-confs folder:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name myapp.*;
include /config/nginx/ssl.conf;
client_max_body_size 0;
location / {
include /config/nginx/proxy.conf;
include /config/nginx/resolver.conf;
set $upstream_app myapp; # container name
set $upstream_port 8080; # internal port
set $upstream_proto http; # http or https
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
}
}
Change myapp in three places (filename, server_name, $upstream_app) and the port, then restart SWAG. To point at something that isn’t a Docker container — a VM, a NAS web UI, a device on your LAN — just use its IP: set $upstream_app 192.168.1.60;. And client_max_body_size 0; removes nginx’s default 1 MB upload limit, which matters for Nextcloud, Immich, and anything else you upload files to.
Step 12 — Test everything
Run through this list:
https://example.comloads SWAG’s welcome page with a valid padlock.https://jellyfin.example.comloads your app with a valid padlock.http://jellyfin.example.com(no s) redirects to HTTPS (only if you forwarded port 80).- It works from outside your network — turn Wi-Fi off on your phone and try over mobile data. This is the real test; a LAN-only test can pass by accident.
- Check your certificate at SSL Labs — SWAG’s defaults should score an A or A+.
- Confirm the cert covers the wildcard:
docker exec swag certbot certificatesshould listDomains: example.com *.example.com.
Hairpin NAT / NAT loopback: some routers won’t let you reach your own public IP from inside your LAN, so jellyfin.example.com works on mobile data but fails at home. Fixes: enable “NAT loopback” / “hairpin NAT” in the router, or run a local DNS server (Pi-hole, AdGuard Home, or Unbound) with a rewrite sending *.example.com to 192.168.1.50. The local-DNS route is better anyway — LAN traffic stops making a pointless round trip to your router.
Maintenance and renewals
Certificate renewal is automatic. SWAG runs Certbot on container start and via a daily cron job; Let’s Encrypt certs renew when 30 days or less remain. You don’t have to do anything — just don’t leave the container stopped for months. Force a renewal manually if you ever need to:
docker exec swag certbot renew --force-renewal docker restart swag
Update SWAG with docker compose pull then docker compose up -d in its folder. After a major update, check the /config/nginx/*.conf.sample files against your live configs — linuxserver.io occasionally changes the defaults, and stale nginx configs are a very common cause of a working setup suddenly 500-ing after an update. Back up these: /config/etc/letsencrypt/ (your certificates), /config/nginx/proxy-confs/ (your proxy configs), /config/dns-conf/cloudflare.ini (your API token), and docker-compose.yml. Watch the logs when something misbehaves:
docker logs swag # container/cert log docker exec swag tail -f /config/log/nginx/error.log docker exec swag tail -f /config/log/nginx/access.log
Optional hardening
Once the basics work, consider layering these on. Fail2ban is already running — check what it’s caught with docker exec swag fail2ban-client status; jail configs live in /config/fail2ban/. An authentication portal (Authelia or Authentik) puts a login page in front of apps with weak or no auth of their own; the proxy-confs samples contain commented-out include /config/nginx/authelia-location.conf; lines you uncomment to protect a service. To restrict a service to LAN only, add inside its location block:
include /config/nginx/authelia-server.conf; allow 192.168.1.0/24; deny all;
SWAG also includes a GeoIP2 module for geoblocking (add a free MaxMind licence key via MAXMINDDB_LICENSE_KEY), and can sit behind a Cloudflare Tunnel if you’d rather not open any inbound ports at all — worth doing if you’re behind CGNAT or your ISP blocks 443 (the same ToS restriction on video applies). Finally, once an app is proxied you can delete its ports: section from its compose file — SWAG reaches it over proxynet and nothing on your LAN can hit it unencrypted. (Keep the ports if you actually use direct LAN access.)
Troubleshooting
Error determining zone_id: 9103 Unknown X-Auth-Key or X-Auth-Email — the token is wrong, or cloudflare.ini still has the old email/global-key lines uncommented alongside the token line. Use one method only.
Error determining zone_id: 6003 Invalid request headers — the token has a typo: a stray space, a missing character, or quotes around it (there should be no quotes). Re-copy it, or create a fresh token.
The request is missing an associated Zone / zone not found — the token isn’t scoped to this domain, or you missed the Zone:Read permission. Edit the token in Cloudflare and check Zone Resources includes example.com.
Failed authorization procedure … DNS problem: NXDOMAIN — the domain isn’t actually using Cloudflare’s nameservers yet. Verify with dig NS example.com +short; it must return *.ns.cloudflare.com. If it returns your registrar’s nameservers, Step 1 didn’t take effect.
too many failed authorizations / rate limited — you’ve burned through Let’s Encrypt’s limits. Set STAGING=true, fix the underlying problem, confirm staging succeeds, then switch back. Production limits reset after a week; the failed-validation limit resets hourly.
Certificate works, but the site shows 502 Bad Gateway — SWAG can’t reach the app. In order: is the app running (docker ps)? Is it on proxynet (docker network inspect proxynet)? Does $upstream_app exactly match the container name (case-sensitive)? Is $upstream_port the internal port, not the host-mapped one? Test from inside SWAG: docker exec swag curl -I http://jellyfin:8096.
Certificate works, but you get SWAG’s default welcome page instead of the app — nginx didn’t match a server_name. Check that the proxy-conf file ends in .conf (not .conf.sample), that server_name jellyfin.*; matches the subdomain you typed, and that you restarted SWAG.
nginx: [emerg] … duplicate server name — two proxy-conf files claim the same subdomain, usually because you enabled both the .subdomain.conf and .subfolder.conf variants. Delete one.
Site loads on mobile data but not at home — hairpin NAT; see the note at the end of Step 12.
It worked yesterday, broken today, nothing changed — your ISP rotated your public IP and the Cloudflare A records are now stale. Compare curl -4 ifconfig.me to the A record value; fix permanently with a DDNS container.
403 Forbidden on a specific app — some apps reject requests whose Host header doesn’t match an allowlist. Look for a “trusted domains”, “trusted proxies”, or “base URL” setting in the app itself — Nextcloud (trusted_domains in config.php) and Home Assistant (trusted_proxies in configuration.yaml) both need this.
Glossary
Reverse proxy — a server that receives requests on behalf of other servers and forwards them, based on hostname or path. SSL/TLS certificate — a file proving your server owns a domain, enabling encrypted HTTPS; Let’s Encrypt issues them free. Wildcard certificate — one cert covering *.example.com, i.e. every subdomain at that level; requires DNS validation. Certbot — the Let’s Encrypt client that requests and renews certificates. DNS validation — proving domain ownership by creating a specific TXT record rather than serving a file over HTTP. A record — a DNS entry mapping a hostname to an IPv4 address. CNAME — a DNS entry mapping one hostname to another hostname. TXT record — arbitrary text attached to a DNS name; used for domain verification, SPF, DKIM. Zone — a domain and all its DNS records, as Cloudflare refers to it. Upstream — in nginx terms, the backend server being proxied to. Port forwarding — a router rule sending inbound traffic on a given port to a specific internal machine. CGNAT — Carrier-Grade NAT, where your ISP shares one public IP among many customers; breaks port forwarding. PUID / PGID — the numeric user and group IDs a container runs as, so files it creates are owned correctly on the host.