Skip to content

Caddy

Curated name caddy. Adds a Caddy reverse proxy to the workbench, running as its own container next to your workspace and configured by a Caddyfile you keep in your repo.

One address for everything the browser talks to. Without a proxy your app answers at one address and each service at another, and anything that cares about the origin has to be worked around: cookies get scoped to the wrong host, redirects leave the app, and an OIDC issuer no longer matches the page that started the login. Put Caddy in front and the browser sees a single origin, which you then split by path: the app on / and the login server on /realms/.

It is language-agnostic. Caddy sits in its own container and talks to your dev server and the other services over the workbench network, so nothing in your project has to know it is there.

At init:

Terminal window
monoceros init acme --with-services=caddy

Or to a workbench you already have:

Terminal window
monoceros add-service acme caddy

Caddy’s configuration is a file called Caddyfile, and it belongs in your project repo: it describes how your app is served, so it is versioned with the app. Keep it at projects/<app>/caddy/Caddyfile and mount the directory it lives in.

The curated entry ships a commented volumes: scaffold. Uncomment it and point it at your project. Paths are relative to the container root, where your repos land under projects/:

acme.yml
services:
- name: caddy
image: caddy:2
port: 81
httpPort: 81
volumes:
- projects/myapp/caddy:/etc/caddy:ro

Then monoceros apply acme, and Caddy comes up with your configuration.

Edits are live: the service runs with --watch, so Caddy re-reads the file when you save it. You do not have to apply or restart anything.

Inside the file you address the targets by their network names. workspace is the container you work in, so that is where your own dev server runs. Every service is named as in the container yml.

projects/myapp/caddy/Caddyfile
{
servers {
trusted_proxies static private_ranges
}
}
:81 {
handle /realms/* {
reverse_proxy keycloak:8080
}
handle {
reverse_proxy workspace:5173
}
}

The handle blocks are tried from the top. The first one takes the paths Keycloak owns and sends them to the Keycloak service on its port 8080. The second has no condition and catches everything else, which goes to the dev server on its port. Add one block per service you want under the same origin.

The site address :81 is the port the curated entry declares, as port and httpPort in the yml. Those three have to agree: change the Caddyfile to another port and change both yml lines with it, or nothing answers. It is 81 rather than 80 because on the host 80 belongs to the machine-wide Traefik proxy, and rather than 8080 because that is what Spring Boot, Tomcat and other app servers take, which would put your proxy and your app on the same number.

Leave the domain name off the site address. Given one, Caddy would try to obtain a TLS certificate for it, which fails here and is not needed: on the host you reach the workbench over plain http, and monoceros share terminates TLS in front of it with the local CA.

reverse_proxy passes the original host and scheme on to the target. That is what lets Keycloak build its issuer from the address the browser actually used, which is why the curated Keycloak entry runs with --proxy-headers=xforwarded.

The trusted_proxies block at the top matters as soon as something sits in front of Caddy, which is what monoceros share does: it terminates HTTPS and forwards to Caddy over plain http, saying so in the headers. Without the block Caddy discards that and reports http to Keycloak, which then stamps an http:// issuer while the browser is on https://, and the login fails. Measured over a real share: http://…/realms/master without it, https://…/realms/master with it. private_ranges covers the workbench network the terminator connects from.

One file for the workbench and the pipeline

Section titled “One file for the workbench and the pipeline”

A deployment differs from the workbench in two details: the port Caddy listens on, and the names of the things it proxies to (workspace:5173 here, your app’s compose service there). Caddy substitutes environment variables anywhere in the Caddyfile, {$VAR:default} included, so both fit in one file:

projects/myapp/caddy/Caddyfile
:{$CADDY_SITE_PORT:81} {
handle /realms/* {
reverse_proxy {$KEYCLOAK_HOST:keycloak}:{$KEYCLOAK_PORT:8080}
}
handle {
reverse_proxy {$APP_HOST:workspace}:{$APP_PORT:5173}
}
}

In the workbench nothing is set and every default applies, so this behaves exactly like the version above. $WORKSPACE_HOST is in the environment too, if you prefer naming it that way. In a pipeline the compose block Monoceros hands you in .monoceros/deploy.md supplies CADDY_SITE_PORT, APP_HOST and APP_PORT, and the file travels unchanged.

Another way is to leave the file alone entirely and map the port outside: ports: ["80:81"] in the deployment compose, or an ingress that targets 81. Then nothing about Caddy changes at all.

A third way, if you would rather keep dev values out of the file: a default is a fallback for both sides, so {$APP_PORT:5173} means a deployment that forgets APP_PORT does not fail. It proxies to 5173, your dev server’s port, in production. Write the variables bare instead:

projects/myapp/caddy/Caddyfile
:{$CADDY_SITE_PORT} {
handle {
reverse_proxy {$APP_HOST}:{$APP_PORT}
}
}

Now the workbench has to supply them as well, and that is what the caddy service’s env: block is for. The curated entry ships it commented, right below the volumes. Uncomment it and keep the keys your Caddyfile reads:

acme.yml
services:
- name: caddy
image: caddy:2
port: 81
httpPort: 81
env:
CADDY_SITE_PORT: ${CADDY_SITE_PORT} # values live in acme.env
APP_HOST: ${APP_HOST}
APP_PORT: ${APP_PORT}

The values belong in acme.env next to the yml, the same place every other service’s do:

acme.env
CADDY_SITE_PORT=81
APP_HOST=workspace
APP_PORT=5173

A forgotten key cannot slip past you: if a service field reads a ${VAR} that acme.env does not set, monoceros apply stops and names it.

The three keys in the scaffold are a starting point, not the set. Caddy substitutes any variable your Caddyfile reads, so the block takes whatever names you chose. Every key is a pair: one line in the yml says the value comes from the env file, one line in acme.env says what it is. Write {$API_PORT} in the Caddyfile and it is these two lines:

acme.yml
env:
API_PORT: ${API_PORT}
acme.env
API_PORT=3000

Rename them, drop the ones you do not use, add the ones you do. That the catalog leaves the block commented is the same point from the other side: a Caddyfile’s variables are its author’s, so there is no fixed set to prefill the way there is for Keycloak’s admin user. The three it suggests are the ones the pipeline block in .monoceros/deploy.md supplies, so a file written against those travels to a deployment unchanged.

A dev server proxied through Caddy keeps serving the page, but its live reload stops working. The reason: the server tells the browser to open the reload connection on the server’s own port, while the browser arrived through Caddy on another one, so the connection goes nowhere.

Fix it in your app’s config by naming the port the browser used. In Vite that is server.ws.clientPort (older versions call it server.hmr.clientPort); other dev servers have an equivalent. Nothing else about the app changes.

An agent in the workbench knows this already: the briefing apply writes into AGENTS.md carries it, together with the rules for the Caddyfile and the fact that the mount belongs in the container yml on the host. So you can tell it to put the app behind the proxy without explaining any of it.

The proxy gives Caddy its own address, acme-caddy.localhost, as soon as the workbench is applied. Nothing to start, nothing to keep running: open it in the browser.

The name is the workbench plus the service, because the Traefik proxy is shared by every workbench on the machine and two of them running Caddy would otherwise claim the same name. monoceros status acme prints the address on the service’s row.

For another device on your network, take monoceros share, which serves the same Caddy over HTTPS. And for something that is not HTTP there is still monoceros tunnel, a raw-TCP forward:

Terminal window
monoceros tunnel acme caddy

See Proxy and tunnels.

Terminal window
monoceros remove-service acme caddy

Or delete the service’s block from acme.yml by hand and re-run monoceros apply acme - the yml is the source of truth.

acme.yml
services:
- name: caddy # host name in the container
image: caddy:2
port: 81 # in-container port (feeds `monoceros tunnel`)
httpPort: 81 # the port that may leave the container
command: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile --watch
# volumes: # uncomment + edit to mount your own files
# - projects/<app>/caddy:/etc/caddy:ro
# env: # uncomment + add the keys your config references
# CADDY_SITE_PORT: ${CADDY_SITE_PORT}
# APP_HOST: ${APP_HOST}
# APP_PORT: ${APP_PORT}

Both ports are 81 and both mean something different. port is what monoceros tunnel acme caddy forwards to, httpPort is what may leave the container: it is the port monoceros share offers to other devices. Your Caddyfile’s site address makes three, and all three carry the same number.

The command is what makes edits live: caddy run keeps the server in the foreground, --adapter caddyfile says the mounted file is a Caddyfile and not Caddy’s own JSON format, and --watch re-reads it on change. There is no data directory, because Caddy only stores certificate state and TLS terminates elsewhere, and no connection env in the workspace, because the traffic runs the other way: Caddy calls your app, not your app Caddy.

The service starts after your repos are cloned, so the Caddyfile is on disk when Caddy boots. Until you mount your own, Caddy runs the file from its image, which serves a welcome page on port 80 and therefore answers on none of the addresses above.