Your own Cloudflare, running on your own box. A self-hosted DNS + CDN +
WAF platform with a Python admin panel β point your domains at it, and get
authoritative DNS, a caching reverse-proxy edge, and a real WAF (block by
IP/CIDR, or by header/URL/body pattern) without handing your traffic to a
third party. Ships as five Docker images and a docker-compose.yml; nothing
to install on the host beyond Docker itself.
Built because "self-hosted ingress + WAF + DNS" for a small VPS or home
server shouldn't require stitching together five different dashboards β one
panel, one docker compose up, and config changes go live instantly, with
no reload, because the whole edge reads its config from Redis on every
request.
- π Real DNS β PowerDNS under the hood, driven entirely through the panel. Zones, A/AAAA/CNAME/MX/TXT/NS/SRV/CAA records.
- π CDN β reverse proxy + caching per domain, powered by OpenResty. Point it at any origin, anywhere.
- π Wildcard domains β
*.example.comin one entry, for services that mint a subdomain per user/tenant. Each matching subdomain still gets its own real Let's Encrypt certificate automatically. - π‘οΈ WAF β block by IP/CIDR, or by rule: match a header, the URL, or the request body, with
contains/regex/exactmatching and block-or-log actions. Plus per-domain rate limiting. - β‘ Zero-reload config β the panel pushes every change straight to Redis; OpenResty's Lua reads it on the next request. No nginx reload, ever, for a domain/rule/IP change.
- π Three HTTPS modes per domain β plain HTTP (no cert hassle), automatic Let's Encrypt, or paste your own certificate.
- π₯οΈ Ingress mode β run it as the public front door on a real server: real client IPs,
proxy_passstraight to anything else you've published to127.0.0.1on the same box. - π Brute-force-hardened login β escalating lockout (doubling up to 24h) on the panel itself.
- π΄ Builds offline β every Python/Lua dependency is vendored and hash-verified;
docker buildnever touches PyPI, GitHub, or luarocks.org. - π Live dashboard β recent WAF events, block counts, all polling live via HTMX.
- π¨ Branded error pages β WAF blocks, rate limits, unknown domains, and origin-down errors all get a custom dark-themed Open-Shield page (never a real origin's own 4xx/5xx, which always pass through untouched).
- β±οΈ Per-domain proxy tuning β connect/send/read timeouts and max body size, plus working WebSocket support.
git clone git@github.com:muerfox/Open-Shield.git
cd Open-Shield
cp .env.example .env
# edit .env: set real passwords/secrets, PDNS_DEFAULT_NS, EDGE_PUBLIC_HOST, ACME_EMAIL
docker compose up --buildOpen https://localhost:8080 and log in with ADMIN_EMAIL / ADMIN_PASSWORD
from your .env. The panel serves HTTPS with a self-signed certificate
(see Panel login security) β your browser will
warn about that on first visit; click through it (or add an exception).
To try it against a local test backend instead of a real domain:
docker compose --profile demo up --buildThis also starts a demo-origin (traefik/whoami) container published to
127.0.0.1:8081 β add a domain in the panel with origin host 127.0.0.1,
origin port 8081 to test against it.
- Domains β Add domain: point it at your real origin (host/port), leave "Proxy through Open-Shield" and "Enable WAF" checked, and pick an HTTPS mode (see below β Off is the default and needs nothing extra).
- DNS β open the new domain's zone, add any extra records you need
(MX, TXT, etc). The apex
Arecord is managed automatically while proxying is on. - If using HTTPS, point your domain's nameservers at this server
(
PDNS_DEFAULT_NS) so PowerDNS actually serves it, and so Let's Encrypt's HTTP-01 challenge can reach the edge on port 80. - WAF β add a rule (e.g. block requests where the
User-Agentheader containssqlmap) or block a specific IP/CIDR. - Logs β watch blocked/logged requests roll in live.
| Component | What it is | Role |
|---|---|---|
panel |
FastAPI + Jinja2/HTMX (Python) | The admin UI: manage domains, DNS records, WAF rules, IP blocks, view logs |
openresty |
OpenResty (nginx + Lua) | The public edge on 80/443: reverse proxy, cache, WAF enforcement, automatic HTTPS |
powerdns |
PowerDNS (pschiffe/pdns-pgsql) |
Real authoritative DNS server for your zones, driven by the panel via its HTTP API |
postgres |
Postgres 16 | Storage for the panel's own config (domains/rules) and for PowerDNS's zone data |
redis |
Redis 7 | Hot-path shared state: domain routing table, WAF rules, IP blocklists, rate-limit counters, ACME cert storage, recent WAF event feed |
Data flow: the panel is the source of truth in Postgres for CDN/WAF config, and pushes a flattened copy into Redis on every change β that's what OpenResty's Lua modules read on the request hot path, so rule/domain changes take effect immediately with no nginx reload. PowerDNS is the source of truth for actual DNS records (queried live via its API). When a domain has "proxy" enabled in the panel, its DNS A record is automatically pointed at the OpenResty edge (the "orange cloud" mechanic) while the real origin is kept separately for OpenResty to connect to.
openresty runs with network_mode: host (Linux-only), which does two
things at once:
- It binds ports 80/443 directly on the server's real network interfaces,
with no Docker NAT in the path β
$remote_addrin nginx (and so the panel's dashboard/log feed) is the genuine client IP. 127.0.0.1inside that container is the host's real loopback, so it canproxy_passstraight to anything you've published to127.0.0.1:<port>on the same machine β your other apps/containers, not just ones in this compose project.
So to front an existing app running elsewhere on the same server: publish
its port to the host's loopback (e.g. another compose project with
ports: ["127.0.0.1:3000:3000"], or a bare process listening on
127.0.0.1:3000), then in the Open-Shield panel add a domain with
origin host 127.0.0.1, origin port 3000.
Everything else in the stack (postgres, redis, powerdns, panel)
stays on the isolated openshield bridge network as before β redis and
panel additionally publish loopback-only ports so the now-host-networked
openresty can still reach them.
Trade-off worth knowing: host networking removes network isolation for
that one container β it can reach (and be reached by) anything else
listening on the host's network stack, not just what you intend it to. If
you don't need the 127.0.0.1-origin use case, you can revert openresty
to normal bridge networking (add back ports: ["80:80", "443:443"] and
networks: [openshield], remove network_mode: host, and point
REDIS_URL back at redis:6379) β real client IPs still work fine in
that mode too for genuine external traffic (Docker's port-publishing DNAT
preserves the original source IP; it's only requests originating from the
host itself, like curl localhost, that show up as the bridge gateway IP,
which is expected and not a real-world concern).
-
Make sure the Docker daemon itself is enabled:
sudo systemctl enable --now docker. Combined withrestart: unless-stoppedon every service, that alone is enough to bring the stack back after a reboot as long as it was running (not manuallydocker compose down'd) beforehand. -
For a fully deterministic boot regardless of prior state, install the provided systemd unit instead:
sudo cp deploy/open-shield.service /etc/systemd/system/ sudo sed -i "s#/opt/open-shield#$(pwd)#" /etc/systemd/system/open-shield.service sudo systemctl daemon-reload sudo systemctl enable --now open-shield.service
This runs
docker compose up -don boot anddocker compose downonsystemctl stop open-shieldβ usesystemctl {start,stop,restart,status} open-shieldinstead ofdocker composedirectly once it's installed.
Each domain has its own HTTPS setting on the domain form:
- Off (default) β served over plain HTTP only, port 80, no redirect to
HTTPS. No DNS/ACME/certificate setup required β this is the easiest way to
get a domain (or
demo-originfor local testing) working end-to-end through the CDN/WAF immediately. - Automatic β the existing Let's Encrypt flow via
lua-resty-auto-ssl. RequiresEDGE_PUBLIC_HOSTset, the domain's DNS actually pointed at this server, port 80 reachable from the public internet for the ACME HTTP-01 challenge, and a real contact email set on the panel's Settings page (not on a domain likeexample.com/.net/.orgβ Let's Encrypt rejects those with aninvalidContacterror). Settings changes take effect within about a minute, no restart needed. A failed issuance attempt backs off for 2 minutes before retrying (rather than retrying β and blocking β on every single request), so a still-broken DNS/firewall setup won't also make the site slow while you fix it. - Manual β paste your own certificate (PEM, full chain) and private key (PEM) directly into the domain form. OpenResty picks it up on the next TLS handshake β no reload needed. Leave both boxes blank on an edit to keep the currently stored cert.
Domains with HTTPS on ("automatic" or "manual") get all plain-HTTP traffic 301-redirected to HTTPS; domains with HTTPS "off" are served on port 80 only (hitting port 443 for one of these still works β the WAF/routing pipeline runs the same way β but you'll get a browser certificate warning, since only the edge's self-signed fallback cert is presented).
Add *.example.com as a domain name (instead of a specific subdomain) to
match any subdomain that doesn't already have its own exact entry β
built for services that mint a subdomain per user/tenant. One entry
handles all of them:
- Origin/cache/rate-limit config is shared across every matching
subdomain β the origin app is expected to look at the
Hostheader itself to figure out which tenant it's serving (the standard pattern for this kind of setup). Rate limiting is still tracked per subdomain though, so one noisy tenant doesn't eat another's quota. - WAF rules and IP/CIDR blocks scoped to the wildcard domain in the panel apply to every subdomain under it.
- DNS: the panel creates a single
*.example.comA record (in theexample.comzone) pointed at the edge β that one record makes every subdomain resolve here, no DNS changes needed as new subdomains appear. - HTTPS: with mode set to Automatic, each subdomain gets issued
its own real Let's Encrypt certificate the first time it's actually
visited (HTTP-01 validates concrete hostnames fine β it just can't issue
the
*.pattern itself β so this isn't a true wildcard cert, it's a real one per subdomain, which needs no special ACME DNS-01 setup and works out of the box). Manual mode works too, if you already have an actual wildcard certificate from elsewhere (DNS-01 via another tool, or purchased) β paste it once and it covers every subdomain.
Only one level of wildcard is supported (*.example.com matches
foo.example.com, not foo.bar.example.com), and an exact entry for a
specific subdomain always takes priority over a wildcard if both exist.
Each domain has connect/send/read timeouts (seconds) and a max
request/upload body size (MB, 0 = unlimited) on its form β bump these
for slow endpoints, long-lived WebSocket connections, or large uploads
that would otherwise hit nginx's 60s/20MB defaults.
This is the one setting in Open-Shield that isn't instant. nginx
doesn't support driving proxy_connect_timeout/proxy_send_timeout/
proxy_read_timeout/client_max_body_size from a variable β they're
config-time-only directives (checked directly against nginx's own docs)
β so there's no way to read them from Redis per-request the way
everything else here works. Instead, openresty/lua/domain_limits.lua
regenerates a small config file from Redis and triggers a graceful
nginx -s reload (new workers with the new config, old ones drained, zero
downtime) whenever it changes β checked every 20s. Domains left on the
default settings don't get a generated block at all and keep using the
shared catch-all server blocks, so this stays a no-op for most domains.
nginx validates the generated config before reloading, so a bad value
can't take the edge down β worst case, the old config keeps running and
the change just doesn't apply until it's fixed.
WebSocket connections also now work through the proxy (an earlier version
of the shared proxy config hardcoded Connection: "", which silently
broke every WebSocket upgrade) β if you run something long-lived over a
WebSocket, raise that domain's read timeout accordingly.
HTTPS by default. uvicorn terminates TLS directly with a self-signed
certificate generated fresh at docker build time (100-year validity,
panel/Dockerfile) β a unique key per build, never committed to the repo.
The session cookie is also marked Secure. This encrypts the connection
so credentials/session cookies can't be sniffed in cleartext on the wire,
even on a LAN β but it's self-signed, so it doesn't give you a trust chain
a browser recognizes: expect a one-time browser warning, and it doesn't
protect against an active on-path attacker presenting their own
self-signed cert instead (true MITM protection needs a real CA-issued
cert β either paste one via the CDN's manual HTTPS mode and proxy the
panel through that, or put a real cert in front of 8080 yourself).
/login also has brute-force lockout built in (panel/app/login_guard.py):
after LOGIN_MAX_ATTEMPTS (default 5) failed attempts from one IP within
LOGIN_FAIL_WINDOW_SECONDS (default 15 min), that IP is locked out of
/login entirely β no password check even attempted β for
LOGIN_LOCKOUT_BASE_SECONDS (default 1 hour), doubling on each further
violation up to LOGIN_LOCKOUT_MAX_SECONDS (default 24h capped). A
successful login clears both the failure count and the escalation level.
Tracked in Redis, so it survives a panel restart. Tune via those env vars
(see .env.example) if the defaults are too strict/loose for you.
Beyond that: use a real random ADMIN_PASSWORD/SESSION_SECRET, and
consider firewalling port 8080 to a trusted network/VPN if you don't need
the panel reachable publicly at all.
Both Dockerfiles are built to need no PyPI/GitHub/luarocks.org network
access at docker build time:
panel/vendor/wheels/has every wheel needed to satisfypanel/requirements.txt(direct deps + full transitive closure, for bothx86_64andaarch64) β the Dockerfile installs withpip install --no-index --find-links=/wheels. Seepanel/vendor/wheels/VERSIONS.md.openresty/vendor/haslua-resty-auto-sslplus everything it normally fetches from GitHub/luarocks.org at build time (lua-resty-http,shell-games,dehydrated,lua-resty-shell'sshell.lua, and thesockprocC source) β the Dockerfile builds/installs all of it vialuarocks makeagainst the local source, and pre-seeds the exact fileslua-resty-auto-ssl's ownMakefilewould otherwisecurldown. Seeopenresty/vendor/VERSIONS.md.
This isn't only a build-time concern, either: panel/app/static/vendor/
has the panel's Pico.css/HTMX/Alpine.js (previously loaded from
cdn.jsdelivr.net/unpkg.com on every page load), so the admin UI itself
renders correctly with no internet access from wherever you're viewing it
β see panel/app/static/vendor/VERSIONS.md.
What's not vendored, and why: each Dockerfile's own OS package install
(apk add ... for Alpine) still hits the base image's configured package
mirror β vendoring raw .apk/.deb binaries into git is fragile (tightly
version/arch-coupled to the exact base image). Pulling the base images
themselves (python:3.12-slim, openresty/openresty:...-alpine-fat,
postgres:16-alpine, redis:7-alpine, pschiffe/pdns-pgsql:alpine,
traefik/whoami) also still needs network the first time β that's
inherent to how Docker registries work. Run docker compose up --build
once with network access, and normal Docker layer caching keeps subsequent
rebuilds offline as long as those base image layers and this vendor/
content don't change.
panel/ FastAPI admin panel (Python)
vendor/wheels/ vendored pip wheels (offline build β see above)
openresty/ Edge: nginx.conf, conf.d/, Lua modules (router, WAF, auto-ssl)
vendor/ vendored lua-resty-auto-ssl + its deps (offline build)
postgres/ DB init script (creates the `panel` and `pdns` databases)
deploy/ Optional systemd unit for boot-start
docker-compose.yml
.env.example
Contributions welcome on any of these:
- Single admin user β no RBAC/multi-tenant support yet.
- IPv4-only CIDR blocking (IPv6 CIDR matching isn't implemented).
- Deleting a domain in the panel does not delete its PowerDNS zone β manage zone deletion from the DNS page if you want that too.
- WAF events are kept in a capped Redis list (last 500), not a full log pipeline β fine for a single-server setup, not built for high-volume forensics.
- HTTPS relies on
lua-resty-auto-sslfor on-the-fly Let's Encrypt certs. That project is archived/unmaintained upstream β it still works (usesdehydratedunder the hood), but acertbot-based alternative would be a reasonable future swap. - No Kubernetes manifests β Docker Compose only, by design.
If a docker compose up --build doesn't come up clean, the likely spots to
check first:
- The vendored
luarocks makebuild chain inopenresty/Dockerfile(openresty/vendor/) β in particular thesockprocC compile and the "stamp file" trick that makeslua-resty-auto-ssl'sMakefileskip its normal network fetches. - Exact PowerDNS env var behavior in
pschiffe/pdns-pgsql(zone auto-creation,webserver-allow-from). ssl_certificate_by_lua_block/ ACME flow, which needs port 80 reachable from the public internet to actually issue certificates.- The vendored wheel set in
panel/vendor/wheels/β ifpip install --no-indexcomplains about a missing/incompatible wheel, it's almost certainly a transitive dependency that's missing; checkpanel/vendor/wheels/VERSIONS.md.
Open an issue with the relevant docker compose logs <service> output and
what you were doing when it broke.
Issues and PRs welcome β bug reports, features from the roadmap above,
docs fixes, all good. No CI pipeline yet, so just make sure docker compose up --build still comes up clean and the feature you touched still works
before opening a PR.
MIT for Open-Shield's own code. Vendored third-party source
under openresty/vendor/ and panel/vendor/ keeps its own original
license (MIT/BSD-2-Clause β see the LICENSE/LICENSE.txt file inside
each vendored project's directory).