ecaddy
📖 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.
How it works
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.
Installation
gem install easy_caddy
ecaddy setup
ecaddy 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.
The --site option
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.
Project setup
Each project needs two things: a Caddyfile and a Procfile line.
1. Write your project Caddyfile
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 |
2. Add a Procfile line
# Procfile.dev
web: bin/rails server -p 3104
js: yarn dev
caddy: ecaddy run --config ./Caddyfile --site fishme
When 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.
3. Allow .localhost in Rails
# config/environments/development.rb
config.hosts << /.*\.localhost/
4. Start your project
bin/dev
Visit https://fishme.localhost — done.
Commands
ecaddy setup
One-time machine bootstrap. Install Caddy, scaffold the global config, trust the local CA, start the brew service.
ecaddy setup
ecaddy run
Register a project Caddyfile, block, and unregister on shutdown. Use in Procfile.dev.
ecaddy run --config ./Caddyfile --site fishme
ecaddy run -c ./Caddyfile -s fishme
On 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.
ecaddy ensure
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 fishme
Useful in CI or shell scripts where you want Caddy configured but don't need a foreground process.
ecaddy list
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 │
└──────────┴────────┴──────────────────────────────────────────────┴────────────┴──────────────────────────┘
ecaddy up NAME / ecaddy down NAME
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, reloads
ecaddy status
Show 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
ecaddy doctor
Scan all registered sites for port/domain conflicts and dead upstreams.
ecaddy doctor
Exits 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) |
ecaddy edit NAME
Open a site's fragment in $EDITOR. Caddy is validated and reloaded after you save.
ecaddy edit fishme
This 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.
ecaddy remove NAME
Remove a site's fragment and registry entry entirely.
ecaddy remove fishme
ecaddy remove fishme --force # skip confirmation
ecaddy reload
Validate the global config and reload Caddy.
ecaddy reload
ecaddy version
ecaddy version
# ecaddy 0.1.0
Global config layout
~/.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.
Conflict detection
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.
Environment variable
Set ECADDY_HOME to override the config root (defaults to ~/.config/caddy). Useful for testing:
ECADDY_HOME=/tmp/ecaddy_test ecaddy list
Development
bin/setup # bundle install
bundle exec rspec # run the full spec suite
bundle exec rubocop # lint
bin/console # IRB session with easy_caddy preloaded
To 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).
Contributing
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.
License
Released under the MIT License.