Skip to content

About

Smart reverse proxy management for Caddy, in one script

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

38 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Smart-Caddy

English · فارسی

Views

Smart reverse proxy management for Caddy, in one script.

Give it a domain → it writes the config, gets the certificate, validates, reloads. If anything is wrong it rolls back automatically, so Caddy never goes down.

$ sudo smart-caddy add panel.example.com 54321

== Pre-flight checks ==
[ ok ] DNS -> 203.0.113.10
[ ok ] backend 127.0.0.1:54321 is reachable
[info] no certificate yet - Caddy will obtain one right after reload

== Applying ==
[ ok ] Caddy reloaded with no downtime.
[info] waiting for certificate issuance....
[ ok ] certificate issued, expires: Dec 20 14:02:11 2026 GMT

== Done ==
[ ok ] https://panel.example.com  ->  127.0.0.1:54321

Install

curl -fsSL https://github.com/Plus98ir/Smart-Caddy/releases/latest/download/smart_caddy.sh -o smart_caddy.sh && sudo bash smart_caddy.sh

That URL always resolves to the newest release — no version number to update.

One file, one command. It installs Caddy itself if missing (apt, dnf, yum, pacman, apk), inspects the machine, and asks only about what it cannot work out alone:

== Looking around ==
[ ok ] package manager: apt
[ ok ] Caddy is installed - v2.11.4
[ ok ] several addresses: 203.0.113.10 203.0.113.11
[warn] :443 is held by 'xray-linux-amd6'

       Caddy cannot share a port with another process. Your options:
         * if that is Xray, keep it and put sites behind its fallback:
               smart-caddy add <domain> <port> --behind-xray
         * if it is an old nginx/apache you no longer want, stop it first
         * or give Caddy its own IP on a multi-address host

== A few questions ==
  Address for Caddy (one of: 203.0.113.10 203.0.113.11) [203.0.113.10]:
  Email for Let's Encrypt (expiry notices) [admin@example.com]:
  Set up the web panel, so you can manage sites in a browser? [Y/n]
  Add your first site now? [y/N]

On a single-address host it never asks about addresses at all. The web panel's source is embedded in the script, so there is nothing else to download.

Why download first instead of piping into bash?

curl ... | bash leaves no terminal to ask questions on, so every prompt would silently take its default. The script detects that and refuses rather than guess.

sudo bash -c "$(curl ...)" passes the whole script as one command-line argument, and Linux caps a single argument at 128 KB. The script is bigger than that, so you get Argument list too long.

sudo bash <(curl ...) fails too: sudo closes the /dev/fd/63 that process substitution creates, so you get /dev/fd/63: No such file or directory.

Saving the file and running it has none of these problems, keeps your terminal attached for the questions, and lets you read the script first if you like:

curl -fsSL https://github.com/Plus98ir/Smart-Caddy/releases/latest/download/smart_caddy.sh -o smart_caddy.sh
less smart_caddy.sh      # read it first
sudo bash smart_caddy.sh

Already installed? Update in place:

sudo smart-caddy update

Features

Never breaks your server caddy validate before every reload; any failure rolls back
Automatic bind always emitted on multi-IP hosts, so no port collisions
Permission-safe writes edits the Caddyfile in place, never clobbering mode or ownership
Certificates on autopilot Caddy issues and renews every certificate itself; the panel shows days left and has a one-click Renew that puts the old cert back if anything fails
Adopts your existing Caddy finds sites you wrote straight into the Caddyfile and imports them unchanged, so they show up in the panel
Path routes send /dns-query/* or /api/* to a different backend than the rest of the site
Reality on :443 x-ui Reality inbounds share :443 with your sites and are kept in step every minute - through DNSGuard, or Caddy's own SNI router (caddy-l4) on servers without it
Real visitor IP behind Cloudflare Cloudflare's edges are trusted and CF-Connecting-IP is read from them only, so apps and Xray log the visitor, not Cloudflare
Updates from the panel Settings → Update checks the latest release by content and installs it; sites stay up
Xray configs behind Cloudflare route a secret path to an XHTTP / gRPC (h2c://) or WebSocket / HTTPUpgrade inbound, so the orange cloud hides the server's IP; the panel has a step-by-step guide
Server identity give each server a name, an icon (flags work) and a colour: shown in the browser tab, favicon, header and sign-in page, so several panels never get mixed up
Activity log every change, from the panel or the terminal: who, when, the output, and a diff of exactly which config lines changed
Many backend types a local port, a TLS backend, a directory of files, another site, or a redirect
Path-aware, not path-fragile records an app's base path without 404-ing the rest, so panel WebSockets keep working
Behind another front proxy when an SNI proxy such as DNSGuard owns :80/:443 and hands names to Caddy on loopback, sites are created behind it and the domain is registered with it for you
Coexists with Xray --behind-xray sets up the loopback + PROXY protocol + h2c listener and verifies the fallback exists
Web panel frosted-glass dark UI in tabs, Persian / English, real login page, PBKDF2 passwords, signed sessions, add / edit / remove sites, raw config editor
Safe removal on delete, asks whether to keep the certificate (default: keep)
doctor listeners, bind, DNS, permissions, certificates that cannot renew, WebSocket pitfalls, Xray fallbacks, recent errors
repair fixes file permissions, stale auth blocks and missing HTTP version pins

Web panel

sudo smart-caddy panel                    # asks for the domain
sudo smart-caddy panel caddy.example.com  # or name it up front

A localhost-only service behind Caddy with TLS. List your sites, add them, edit them, remove them, and run the diagnostics — all by calling the same CLI underneath, so there is exactly one code path that writes config.

A dark frosted-glass interface, Persian and English (right-to-left in Persian), organised in tabs, each with a title and a short explanation:

  • Overview — Caddy's state, certificate counts, expiring certificates, and quick actions.
  • Sites — every site with its backend, routes, and how many days of SSL are left, coloured as expiry gets close, with a Renew button.
  • Add / Edit site — first what are you putting online? (a website, an x-ui panel, an Xray config through a CDN) with a step-by-step guide and Fill in from x-ui (panel port, base path and subscription read from x-ui); then one form with an explanation under every field: a name for the site, main backend, path routes (each with a ticked type: Web / Sub, WebSocket / HTTPUpgrade or XHTTP / gRPC; the leading / and trailing * are added for you), SSL certificate (Caddy, existing certbot, or self-signed), path prefix, Host header, admin-panel preset, plain HTTP, behind Xray.
  • Config — edit any site's raw Caddyfile text. It is validated first and rolled back if Caddy rejects it, so a typo cannot take the server down.
  • Import — sites written by hand into the Caddyfile, with one-click import. A domain defined twice gets a "keep this one" button.
  • Cloudflare CDN — which Xray transports work behind Cloudflare and the exact steps in x-ui, here and in Cloudflare.
  • Diagnostics and Activity — the full check, the output of every action, and each change with a coloured diff of the config files it touched.
  • Reality — every x-ui Reality inbound, whether it is on :443, its share links, Put on :443, and on a server without DNSGuard the switch for Caddy's own SNI router. See Reality on :443.
  • Settings — Update (check the latest release and install it from the page) and the server's identity: a name, an icon and an accent colour, shown in the browser tab, favicon, header and sign-in page. Stored in /etc/smart-caddy-brand.json, untouched by updates.

It has a real sign-in page, not the browser's native credential box: passwords are PBKDF2-HMAC-SHA256 (240k rounds) in /etc/smart-caddy-panel.json (mode 0600), sessions are HMAC-signed cookies (HttpOnly, SameSite=Strict, Secure behind HTTPS) that survive a restart, cross-origin POSTs are refused, and failed logins back off exponentially after five tries.

Change the password from the terminal at any time:

sudo smart-caddy passwd                          # prompts twice
sudo smart-caddy passwd --password 'NewPass123'
sudo smart-caddy passwd newuser                  # change the username too

Changing it rotates the session key, so anyone already logged in is signed out.

Notes:

  • The service binds 127.0.0.1 only. It authenticates, but speaks plain HTTP — keep Caddy in front of it rather than exposing the port.
  • It runs as root, because it edits /etc/caddy and reloads the service.
  • Every field is validated against a strict allowlist before reaching a subprocess, and commands are built as argv lists, never shell strings.

Commands

setup                                    one command, start to finish
install [--ip <IP>] [--email <mail>]     just the system wiring
add <domain> <backend> [opts]            add a site
del <domain> [--keep-cert|--purge-cert]  remove a site
del-cert <domain>                        remove only the certificate
list                                     list sites and certificates
import [domain...] [--all|--list]        adopt sites already in the Caddyfile
import <domain> --replace                ... over a managed copy of the same domain
put <domain> <file>                      replace a site's config with your own text
cert <domain> caddy                      let Caddy issue and renew the certificate
renew <domain>                           get a fresh certificate now (rolls back on failure)
panel <domain> [--user u] [--port n]     install the web panel
panel status | panel remove [<domain>]   check or remove the web panel
passwd [user] [--password <p>]           change the panel password
doctor                                   full diagnostic
fixbind                                  add 'bind' to blocks missing it
repair                                   fix permissions and known upgrade issues
update [--force]                         install the latest release (also in the panel)
reality [status|sync|on443 <id>]         x-ui Reality inbounds on :443
sni [status|enable|disable]              Caddy's own SNI router (caddy-l4)
uninstall                                remove integration (certs kept)

Run add or panel with no arguments and they ask for what they need, validating as they go.

Backends

You write It means
54321 a port on this machine
127.0.0.1:54321 the same, spelled out
https://127.0.0.1:27389 a local app that serves TLS itself (pair with --insecure)
10.0.0.5:9000 a service on another machine
/var/www/mysite serve files from that directory
example.com proxy through to somebody else's site
redirect:https://x.com send visitors there instead

If you give a plain host:port but the backend actually speaks HTTPS, the script detects it and offers to switch — that mismatch is the most common cause of a 502.

add options

Option Effect
--route <path>=<backend> send one path to another backend, e.g. --route '/dns-query/*=8000'. Repeatable.
--replace overwrite an existing site in place (keeps its certificate and its Xray loopback port)
--path <prefix> record the app's base path, shown in list. Repeatable.
--strict-path additionally refuse everything outside those prefixes
--behind-xray Xray owns :443 and falls back to us
--listen-port <n> pick the loopback port yourself (default: first free in 8081-8199)
--panel preset for admin panels — implies --insecure --no-buffer
--insecure don't verify the backend's TLS certificate
--no-buffer stream responses through — live stats, SSE, log tails
--host-header <v> force the Host header sent upstream (routers want 127.0.0.1)
--auto-cert Caddy issues and renews the certificate itself (recommended)
--certbot use the existing certbot certificate
--self-signed tls internal — Caddy's internal CA, for testing
--no-tls plain HTTP
--no-dns-check skip the A-record check
-y, --yes non-interactive

Admin panels (x-ui, 3x-ui, Marzban, Hiddify)

These do three awkward things at once: they serve HTTPS with a self-signed cert on localhost, they live under a random base path, and their dashboard streams live traffic counters. Miss any one and you get a blank page, a 502, or a panel that opens but never updates.

sudo smart-caddy add panel.example.com https://127.0.0.1:27389 \
    --panel --path /5wSobQvUFuNy4zBUcc

which generates:

panel.example.com {
	bind 203.0.113.10
	encode zstd gzip

	# app base path: /5wSobQvUFuNy4zBUcc
	reverse_proxy https://127.0.0.1:27389 {
		transport http {
			tls_insecure_skip_verify
			versions 1.1
		}
		header_up Host {host}
		header_up X-Real-IP {remote_host}
		header_up X-Forwarded-Proto {scheme}
		header_up X-Forwarded-Port {server_port}

		flush_interval -1
	}
}

Three details matter, and each one fails the same invisible way:

  • flush_interval -1 — Caddy's equivalent of nginx's proxy_buffering off. Without it the dashboard's live counters sit in a buffer and look frozen.
  • versions 1.1 — a TLS backend can negotiate HTTP/2 over ALPN, and HTTP/2 has no Upgrade mechanism, so WebSocket handshakes silently fail.
  • Never force the Host header on a panel. Panels check the WebSocket's Origin against the Host they receive. Sending Host: 127.0.0.1 while the browser sends Origin: https://panel.example.com makes that check fail, the WebSocket is refused, and the speed columns stay empty forever. add warns you if you try.

Why --path does not restrict by default

--path records where the app lives; it does not block anything else. Admin panels routinely serve their base path from one place and open their live-stats WebSocket from the site root, so matching on the prefix and 404-ing the remainder produces a panel that looks completely healthy while the live traffic columns stay permanently blank — a genuinely hard bug to spot, because every page you click works.

The app already 404s its own root, so the extra restriction usually buys nothing. --strict-path restores the strict matcher if you really want it, with a warning.

Already running Caddy?

Sites you wrote by hand live in the Caddyfile itself, where nothing can see them. setup and install notice them and offer to import; you can also do it any time:

sudo smart-caddy import --list     # what is there
sudo smart-caddy import            # move them into sites.d

Each block moves byte for byte into /etc/caddy/sites.d/<domain>.caddy, so Caddy serves exactly what it served before. The Caddyfile is backed up first, and the move is validated and rolled back like any other change. Blocks with no domain name (:80, an IP, localhost) and snippets stay where they are.

Imported sites are read back into the panel's form when they use directives it understands — reverse proxies, path routes, files, redirects. Anything more exotic is edited as raw text with Config.

Path routes

One domain, several backends. A DNS-over-HTTPS server with its own admin page and a public landing page, for example:

sudo smart-caddy add dns.example.com 8088 \
    --route '/dns-query/*=8000' --route '/panel*=8000'

Each route becomes a handle block, and the main backend takes everything else:

	handle /dns-query/* {
		reverse_proxy 127.0.0.1:8000 { ... }
	}
	handle /panel* {
		reverse_proxy 127.0.0.1:8000 { ... }
	}
	handle {
		reverse_proxy 127.0.0.1:8088 { ... }
	}

Xray configs behind Cloudflare

Put Caddy between Cloudflare and an Xray inbound: a ping of the domain returns Cloudflare's IP, while traffic still leaves from this server. One domain carries several configs, each on its own secret path.

Transport Through Cloudflare Route type
XHTTP yes (packet-up everywhere; stream modes need gRPC on in Cloudflare) h2c://127.0.0.1:PORT
gRPC yes (switch on Network → gRPC) h2c://127.0.0.1:PORT
WebSocket, HTTPUpgrade yes 127.0.0.1:PORT
Reality, TCP, Hysteria2, Shadowsocks no — Cloudflare only carries HTTP —
  1. In x-ui, the inbound listens on 127.0.0.1, security none (TLS ends at Caddy — an inbound with its own TLS puts pinnedPeerCertSha256 in the share link, and behind Cloudflare that pin never matches), a long random path, and your CDN domain as Host.

  2. Add the site with the cloud still grey, a cover page as the main backend, and one route per config:

    sudo smart-caddy add cdn.example.com redirect:https://www.google.com \
        --route '/k8Jq2xVt*=h2c://127.0.0.1:3103' --route '/ws9Lp*=3305'
  3. Once the certificate is issued, turn the cloud orange; SSL/TLS Full (strict); keep Always Use HTTPS off so Caddy can renew over port 80.

  4. Client: address = the domain or a clean Cloudflare IP, port 443, TLS, SNI and Host = the domain; ALPN h2,http/1.1 for XHTTP/gRPC, http/1.1 for WebSocket/HTTPUpgrade.

h2c:// routes are streamed without buffering, and a site that has one is served without compression, which would otherwise hold the stream back.

Certificates

By default Caddy gets every certificate from Let's Encrypt and renews it about 30 days before expiry, with nothing else to run. list and the panel show what each site actually uses and how long it has left.

A certbot certificate from before Caddy can quietly stop renewing: its standalone mode needs port 80, which Caddy now holds, and its nginx mode needs nginx. doctor flags that case, and one command hands the certificate to Caddy without downtime:

sudo smart-caddy cert panel.example.com caddy

renew fetches a fresh certificate on demand. Caddy keeps certificates in memory, so it restarts Caddy for about a second; the current certificate is set aside first and put back if no new one arrives. Let's Encrypt allows five renewals of the same name per week.

Activity log

Every command that changes something — from the panel or the terminal — appends one entry to /var/log/smart-caddy/activity.jsonl (the last 500 are kept): when, who (user@ip from the panel), the command, its output, and a unified diff of every file it touched. The panel shows it at the bottom of the page.

Sharing port 443 with Xray

Two processes cannot bind the same IP:port. But if Xray is already on :443, it is also already a perfectly good multiplexer: it terminates TLS and dispatches by SNI, ALPN and path to local ports. Caddy goes behind it.

sudo smart-caddy add shop.example.com 3000 --behind-xray
[ ok ] picked free loopback port 8082

== Done - one step left, in Xray ==
[ ok ] listening on 127.0.0.1:8082 (plain HTTP, PROXY protocol v2)

    SNI    shop.example.com
    ALPN   (leave empty)
    Path   /
    Dest   127.0.0.1:8082
    xver   2

Paste that into x-ui → the inbound on 443 → Fallbacks → Add fallback.

Three things have to line up, and getting any one wrong looks identical — a domain that never responds:

  • Plain HTTP, not HTTPS. Xray decrypts before forwarding, so the site is http://domain:PORT bound to 127.0.0.1 with no certificate.
  • PROXY protocol. xver 2 prefixes each connection with a binary header. Without the matching listener wrapper, Caddy reads it as a malformed request.
  • h2c. An alpn h2 fallback arrives as cleartext HTTP/2, which a plain listener does not speak by default.

--behind-xray writes all three and adds the listener stanza to the global options block. The certificate belongs to the Xray inbound, not to Caddy — Xray does the handshake, so this listener never sees one.

doctor then reads your Xray config and tells you whether the fallback row exists:

== 7  Xray fallbacks ==
[ ok ] shop.example.com: Xray falls back to :8082
[warn] api.example.com: no Xray fallback points at :8083
       the domain will look dead until you add that row in x-ui

Behind an SNI proxy (DNSGuard and similar)

Some servers run an SNI proxy on :80/:443 that serves its own clients and hands the names it does not handle to Caddy on a loopback port, with a PROXY protocol header. Caddy's global options then contain https_port <n> with n other than 443.

Smart Caddy detects that and stops suggesting --behind-xray. Every site it creates there is an ordinary HTTPS site bound to 127.0.0.1, with an explicit http → https redirect when the front proxy has turned Caddy's own redirect off. For DNSGuard it also adds the domain to SNI_LOCAL_NAMES in /opt/dnsguard/.env and restarts it (DNS pauses for a few seconds); for anything else it tells you which name to forward. doctor lists any site the front proxy does not hand over yet.

What does not work

Putting a stock Caddy in front of Reality on :443: an HTTP server terminates TLS and expects HTTP, and VLESS/Reality is neither. With the caddy-l4 module it works - see Reality on :443.

Reality on :443

An x-ui Reality inbound normally needs a port of its own, because Caddy holds :443. Smart Caddy can put it on :443 next to your sites: something on :443 reads only the SNI (the name in the TLS hello, without decrypting anything) and hands a Reality name, untouched, to the inbound on loopback, and every other name to Caddy.

That "something" is one of two routers:

  • DNSGuard, when it already owns :443 in front of Caddy - the names go into its SNI_PASS.
  • Caddy's own SNI router, on any other server: smart-caddy sni enable (or the Turn it on button on the panel's Reality tab) adds the caddy-l4 module, moves the sites to 127.0.0.1:8444 / :8081 and lets Caddy route :443 and :80 by name. The sites keep their addresses and certificates, and still see the visitor's IP (PROXY protocol). One process, nothing else to install; the caddy package is held so an apt upgrade cannot drop the module.

Then it keeps itself in step: in x-ui make a Reality inbound with Listen IP 127.0.0.1, any free port, and its own SNI. Within a minute the panel gives that SNI its route and points the share link at port 443; change the SNI or delete the inbound and the route follows. Several Realities work side by side, one SNI each.

sudo smart-caddy sni enable        # only where Caddy holds :443 itself
sudo smart-caddy reality           # every Reality inbound, on :443 or not
sudo smart-caddy reality on443 8   # move inbound 8 to 127.0.0.1 and onto :443
sudo smart-caddy reality sync      # what the panel does every minute

Pick an SNI that is not one of your own sites, not blocked where your users are, and not used by another Reality. A big foreign site on your own IP is a known detection signal; a subdomain of your own (with Reality's target pointing at Caddy) avoids it.

DNS

Put your subdomains on DNS-only. If your provider has a CDN or "cloud proxy" toggle (Cloudflare's orange cloud, ArvanCloud's ابر), leave it off for anything Caddy serves: with it on, the A record returns the CDN's IP, the ACME challenge never reaches your server, no certificate is issued, and all your traffic — session cookies included — passes through the CDN.

Troubleshooting

sudo smart-caddy doctor
Symptom Usual cause
reload failed a block has no bind → smart-caddy fixbind
permission denied on the Caddyfile mode/ownership clobbered → smart-caddy repair
Browser shows a native login popup an old basic_auth block → smart-caddy repair
Certificate never issued A record points elsewhere, CDN proxy on, or port 80 closed
certbot certificate about to expire certbot cannot renew while Caddy holds :80 → smart-caddy cert <domain> caddy
502 Bad Gateway backend down, or it speaks HTTPS and you gave a plain host:port
Panel loads but live stats stay empty a forced Host header, a --strict-path restriction, buffering, or a missing versions 1.1
address already in use another service holds that IP:port — ss -tnlp | grep ':443'

A note on file permissions

Caddy's systemd unit runs as an unprivileged user, so /etc/caddy/Caddyfile must stay readable by it. Editing that file with mktemp + mv silently replaces its mode and ownership with 0600 root:root, and every later reload fails with permission denied. This script always edits in place, preserving inode, mode and owner. If something else already did the damage, smart-caddy repair fixes it.

Requirements

caddy, bash 4+, systemd, and python3 for the web panel. Recommended: acl (setfacl), dnsutils (dig), netcat. setup installs what it can.

License

MIT

About

Smart reverse proxy management for Caddy, in one script

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages