📖 Documentation | GitHub | Changelog
One global Caddy for all your local Rails projects.
Instead of fighting port conflicts from multiple Caddy processes, ecaddy manages a single shared Caddy instance. Each project keeps its own Caddyfile — ecaddy copies it in and out of the global config on demand.
Browser
│
▼
Caddy (~/.config/caddy/Caddyfile)
│ imports sites/*.caddy
├── fishme.localhost → localhost:3104
├── letly.localhost → localhost:3100
└── traiderb.localhost → localhost:3106
Each Rails project has its own Caddyfile. When you start the project, ecaddy copies it into ~/.config/caddy/sites/<name>.caddy and reloads the global Caddy. When you stop, it removes the fragment and reloads again.
gem install easy_caddy
ecaddy setupecaddy setup is a one-time bootstrap that:
- Installs Caddy via Homebrew if not already present
- Scaffolds
~/.config/caddy/{sites,disabled}/ - Writes the global
Caddyfile(withimport sites/*.caddy) - Symlinks it into
/opt/homebrew/etc/Caddyfilesobrew servicespicks it up - Runs
caddy trustto install the local CA in your system keychain (makeshttps://*.localhostgreen in browsers) - Starts Caddy as a
brew servicesbackground service
Run ecaddy setup again at any time — every step is idempotent.
Every ecaddy command that registers or references a project uses a site name — a short identifier you choose, e.g. fishme. This name:
- Determines the fragment filename:
~/.config/caddy/sites/fishme.caddy - Is used by
up,down,edit,removeto target the right project - Should be unique across all your local projects
The name is not read from the Caddyfile — you always supply it explicitly with --site fishme (short: -s fishme). This keeps ecaddy compatible with any Caddyfile content.
Each project needs two things: a Caddyfile and a Procfile line.
Put a Caddyfile in your project root. Write it however you need — ecaddy treats it as read-only source. One automatic transform is applied on copy: relative output file log paths are rewritten to absolute paths so Caddy (running as a background service with no relation to your project directory) can actually write the log files.
# Caddyfile (in your Rails project root)
fishme.localhost {
handle /vite-dev/* {
reverse_proxy localhost:3054
}
reverse_proxy localhost:3104
tls internal
log {
level INFO
output file log/caddy.log {
roll_size 2mb
roll_keep 5
roll_keep_for 48h
}
}
}
vite.fishme.localhost {
reverse_proxy localhost:3054
tls internal
}Pick unique ports across your projects. Common pattern:
| Project | App port | Vite port |
|---|---|---|
| fishme | 3104 | 3054 |
| letly | 3100 | 3050 |
| traiderb | 3106 | 3056 |
# Procfile.dev
web: bin/rails server -p 3104
js: yarn dev
caddy: ecaddy run --config ./Caddyfile --site fishmeWhen foreman (or overmind) starts, ecaddy run copies your Caddyfile into the global config and reloads Caddy. When you press Ctrl-C, it removes the fragment and reloads again — the domain disappears cleanly.
# config/environments/development.rb
config.hosts << /.*\.localhost/bin/devVisit https://fishme.localhost — done.
One-time machine bootstrap. Install Caddy, scaffold the global config, trust the local CA, start the brew service.
ecaddy setupRegister a project Caddyfile, block, and unregister on shutdown. Use in Procfile.dev.
ecaddy run --config ./Caddyfile --site fishme
ecaddy run -c ./Caddyfile -s fishmeOn SIGTERM or SIGINT, the fragment is removed and Caddy is reloaded before the process exits.
Relative output file log paths in the Caddyfile are automatically rewritten to absolute paths (resolved from the directory of --config) before the fragment is installed.
One-shot variant of run. Copies the Caddyfile, reloads Caddy, exits immediately. The site stays registered until you run ecaddy down or ecaddy remove.
ecaddy ensure --config ./Caddyfile --site fishmeUseful in CI or shell scripts where you want Caddy configured but don't need a foreground process.
Show all registered sites.
ecaddy list
ecaddy list --format json┌──────────┬────────┬──────────────────────────────────────────────┬────────────┬──────────────────────────┐
│ Name │ Status │ Domains │ Ports │ Source │
├──────────┼────────┼──────────────────────────────────────────────┼────────────┼──────────────────────────┤
│ fishme │ up │ fishme.localhost, vite.fishme.localhost │ 3054, 3104 │ /projects/fishme/Caddyfile │
│ letly │ down │ letly.localhost, vite.letly.localhost │ 3050, 3100 │ /projects/letly/Caddyfile │
└──────────┴────────┴──────────────────────────────────────────────┴────────────┴──────────────────────────┘
Enable or disable a registered site without removing it.
ecaddy down fishme # moves sites/fishme.caddy → disabled/fishme.caddy, reloads
ecaddy up fishme # moves disabled/fishme.caddy → sites/fishme.caddy, reloadsShow global Caddy state and per-site health (whether the upstream app is actually running).
ecaddy status Caddy service: running
Config: /Users/you/.config/caddy/Caddyfile
fishme up
fragment: /Users/you/.config/caddy/sites/fishme.caddy
source: /projects/fishme/Caddyfile
letly up (app not running)
fragment: /Users/you/.config/caddy/sites/letly.caddy
source: /projects/letly/Caddyfile
Scan all registered sites for port/domain conflicts and dead upstreams.
ecaddy doctorExits 0 if all clear or only INFO findings. Exits 1 on any BLOCK.
| Severity | Meaning |
|---|---|
BLOCK |
Two sites share a port or domain — one will fail |
WARN |
A port is bound by an unexpected process |
INFO |
Upstream not listening (app not started) |
Open a site's fragment in $EDITOR. Caddy is validated and reloaded after you save.
ecaddy edit fishmeThis edits the copy in ~/.config/caddy/sites/fishme.caddy, not your project source. Re-run ecaddy run (or ecaddy ensure) to sync from your project Caddyfile again.
Remove a site's fragment and registry entry entirely.
ecaddy remove fishme
ecaddy remove fishme --force # skip confirmationValidate the global config and reload Caddy.
ecaddy reloadecaddy version
# ecaddy 0.1.0~/.config/caddy/
Caddyfile # global root: { admin ... } + import sites/*.caddy
ecaddy.yml # registry: name → { enabled, source_path }
sites/
fishme.caddy # enabled fragments — loaded by Caddy
letly.caddy
disabled/
traiderb.caddy # disabled fragments — preserved, not loaded
The global Caddyfile is also symlinked at /opt/homebrew/etc/Caddyfile so brew services start caddy picks it up automatically.
Before registering any Caddyfile, ecaddy parses it and checks:
- Domain collision — same
*.localhostdomain already registered by another enabled site → BLOCK - Port collision — same
reverse_proxy localhost:PORTalready in use by another site → BLOCK
These checks run on ecaddy run, ecaddy ensure, and ecaddy up. Run ecaddy doctor at any time for a full cross-site audit.
Set ECADDY_HOME to override the config root (defaults to ~/.config/caddy). Useful for testing:
ECADDY_HOME=/tmp/ecaddy_test ecaddy listbin/setup # bundle install
bundle exec rspec # run the full spec suite
bundle exec rubocop # lint
bin/console # IRB session with easy_caddy preloadedTo run the CLI against the local source without installing the gem:
bundle exec exe/ecaddy <command>Cutting a release: bump EasyCaddy::VERSION in lib/easy_caddy/version.rb, add a CHANGELOG.md entry, commit, then bundle exec rake release — that tags the commit and pushes the gem to RubyGems (requires gem signin first).
Bug reports and pull requests are welcome at https://github.com/pniemczyk/easy_caddy. Please run the spec suite and rubocop before opening a PR.
Released under the MIT License.