Running

Networking

Services in a project talk to each other over a private Docker network, by name. Caddy sits in front and sends each public hostname to whichever container is active right now.

Private network

Every project has one bridge network named shed-<projectID>. shed creates it when the project's first container starts and removes it when you delete the project. A project's services never share a network with another project's, so a name only resolves inside its own project.

Each deployment runs as one container named shed-<serviceID>-<deploymentID> with three labels, shed.project, shed.service, and shed.deployment. shed finds containers by these labels when it reconciles after a restart, samples metrics, and cleans up after a failed deploy. The restart policy is unless-stopped.

The active container carries the service name as a network alias. That alias is the service's private host: Docker's embedded DNS resolves postgres to the container's address, so an app connects to postgres:5432. Use the container port. Nothing is published on the host unless you set a public TCP port.

network shed-<projectID>Caddyin the shed processwebalias web · 172.18.0.4postgresalias postgres · 172.18.0.2web, next deploymentno alias until healthyredisalias redis · 172.18.0.3postgres:5432redis:6379Container nameshed-<serviceID>-<deploymentID>
One project network. Caddy reaches containers by IP; services reach each other by alias. A candidate has no alias until it is healthy.

The alias moves last. A new container starts with only its container name resolvable, so web keeps pointing at the old container while the new one boots. Once the candidate passes its health check, shed disconnects and reconnects it with the alias, asking Docker for the address it already had, and checks it again. The previous container is disconnected from the network before its graceful stop, so private traffic only ever reaches the new one. See zero-downtime switchover.

The proxy

shed embeds Caddy and drives it with a generated JSON config. There is a single HTTPS server, shed, listening on proxy.https_port (443 by default). Plain HTTP on proxy.http_port serves ACME challenges and redirects to HTTPS. Caddy's admin API is disabled, so the only way routes change is through shed.

RequestHost headerCaddyserver shed · :443shed.example.comto 127.0.0.1:3000 (server.listen)app.example.comto 172.18.0.4:8080 (active container)any other hoststatic_response 404Routes are sorted by host and each one is terminal; the 404 handler is last.
Caddy matches the Host header against one route per hostname. Routes are terminal, and the 404 catch-all is always last.
  • Dashboard route. The hostname of server.url goes to server.listen. A listen address of 0.0.0.0 or an empty host is dialed as 127.0.0.1.
  • Service routes. Each domain of a service goes to <container IP>:<port> of its active deployment, on the project network. The port is the one recorded on that deployment, so editing a service's port applies from its next deployment.
  • No route exists for a service that is stopped, has no active deployment, has no port, or whose container is gone. If shed cannot find out (a Docker or database error), it fails the update and Caddy keeps its last config rather than dropping services.
  • Reloads. shed recomputes all routes after anything that changes domains or the active deployment. Hostnames are lowercased and sorted, and the config is only reloaded when the JSON actually changed. Two routes with the same hostname are rejected.
  • Failed updates after a domain change. Adding or removing a domain saves it first, then updates the routes, with a 30-second context deadline. If that fails, the request still succeeds, since the domain is saved, but the response carries the header Shed-Routes: pending and shed logs the error. shed then retries in the background, immediately, then after 2 seconds and doubling up to once a minute, until the routes apply, and logs when they do. Caddy keeps serving its last config meanwhile.
  • Certificates. Names a public CA can issue for use ACME, with proxy.acme_email as contact when set. Anything else, like an IP address or localhost, gets a certificate from Caddy's internal CA, which browsers will not trust. Caddy never touches the host's trust store.

Try it on a sample route table. The matcher ignores case and a port suffix, like Caddy's host matcher.

Try a Host header
HostGoes toUpstream
shed.example.comdashboard and API127.0.0.1:3000
app.example.comdemo / web172.18.0.4:8080
web-demo.apps.example.comdemo / web172.18.0.4:8080
api.example.comshop / api172.19.0.2:3000

Sample routes above. Case and a port suffix are ignored.

Custom domains

A service can have any number of domains. Point a DNS A or AAAA record at the server, then add the hostname to the service. Caddy asks for the certificate as soon as the route loads, so DNS has to resolve first or issuance fails until it does.

The first domain you add is exposed to the service as SHED_PUBLIC_DOMAIN. shed validates the name before saving it:

RuleResult
Lowercased and trimmed; every label is 1 to 63 characters of a-z, 0-9 and hyphens, not starting or ending with a hyphen; at most 253 characters in total400 otherwise
The hostname is already used by any service409
The hostname is the dashboard host, the host of server.url409, the dashboard hostname is reserved

The dashboard hostname is reserved because a service route and the dashboard route would otherwise compete for the same Host. shed also refuses to load a config with duplicate hosts, so the dashboard route can never be replaced by a workload. A service with domains but no port is not routed: the build log says No port, so its domains are not routed.

Cloudflare

A custom domain can sit behind Cloudflare's proxy (the orange cloud on its DNS record). Set the zone's SSL/TLS mode to Full (strict): Caddy still holds its own certificate, and in Flexible mode Cloudflare connects over plain HTTP, which Caddy redirects to HTTPS in a loop. Caddy keeps renewing through the HTTP challenge, which Cloudflare passes through.

Behind the proxy every request arrives from a Cloudflare address. Set proxy.cloudflare = true and Caddy trusts Cloudflare's edge ranges: for requests from those addresses it takes the client IP from CF-Connecting-IP and keeps the X-Forwarded-Proto Cloudflare sets. Requests from anywhere else, such as domains left unproxied or someone connecting to the server's IP directly, use the connection's address, and their forwarding headers are ignored, so they cannot spoof a client IP. Either way, services receive X-Forwarded-For holding exactly one address, the visitor's, and X-Forwarded-Host set to the requested host, never a value the visitor sent. Proxied and unproxied domains can be mixed freely.

The ranges are built into shed, from cloudflare.com/ips.

Some limits come from Cloudflare, not shed. The free plan's Universal SSL certificate covers the zone apex and one level of subdomain, so generated domains under a nested base_domain like *.apps.example.com can't be proxied without an advanced certificate. Proxied requests time out after 100 seconds and request bodies are capped at 100 MB on the free plan. The server's IP is still public through any unproxied record.

Generated domains

If proxy.base_domain is set, adding a domain without a hostname generates one: <service>-<project>.<base_domain>. The project part is its name reduced to ASCII letters and digits, with each run of anything else becoming one hyphen. The label is cut at 63 characters, and the name is computed once, when you create it. Renaming the project later does not change it.

Generated hosts need wildcard DNS: *.apps.example.com pointing at the server. Each generated host still gets its own certificate. Without base_domain the request fails with 400 and you can only add custom domains.

Generated domain preview

web-my-shop.apps.example.com

Public TCP ports

Set a public port on a service and Docker publishes it on the host: 0.0.0.0:<publicPort> to the container port, TCP. Traffic goes straight to the container and never through Caddy, so there is no TLS termination, no hostname routing, and no certificate. It is how you expose a database or a game server. A public port needs a container port, and both are 0 to 65535.

Docker maintains its own firewall rules for published ports, which can bypass host firewalls such as ufw. Treat a public port as open to the internet.

Two containers cannot bind the same host port, so a service with a public port (or with volumes) deploys stop-first: shed stops the old container before it starts the new one, and there is a short outage. See replacement storage safety.

Health checks

A new container only gets traffic after it proves it is up. With a container port, shed probes the container's IP on the project network. Without a health check path that is a TCP connect. With a path it is an HTTP GET that must answer 2xx or 3xx. Redirects are not followed, and a 4xx or 5xx fails the probe. Each probe times out after 5 seconds.

Startno alias yetProbe1s tick · 2m maxAdd aliassame IPProbe again10s maxRoute + retireold one stopsUntil the last step, the previous container keeps the alias and the routes.
The health check bookends the alias change. A failure at any step removes the candidate and leaves the previous container serving.
SettingValue
probe interval1s
deadline120s from container start
progress line"Not ready yet: <error>" every 5s
no container portno probe; watched 3s, fails if it exits
after the alias movesprobe again, up to 10s

A container that exits (or sits in restarting) fails the deployment immediately with its exit code, and its last output is copied into the build log. The second probe exists because reconnecting a container to the network could disturb it. If it fails, the deployment fails and the previous container keeps the alias and the routes.

Replay a health check

0 means no port, so shed only watches the container.

Empty means a TCP connect.

7s
0s==> Waiting for /health to become healthy
0sProbing GET /health on 172.18.0.4:8080 (timeout 2m0s)
5sNot ready yet: Get "http://172.18.0.4:8080/health": dial tcp 172.18.0.4:8080: connect: connection refused
7sHealthy after 7s
7s==> Switching traffic
7sStill healthy at 172.18.0.4:8080
7sPrivate host web resolves to the new container