Clusters & load balancing
A cluster fronts one or more hostnames and routes traffic to a set of upstreams grouped under named variants. The proxy is an ordinary OwnStack app owned by the cluster — same logs / restart / SSH / scale / maintenance as anything else. You pick how the edge solves "LB the LB" when you create the cluster: DNS round-robin, Cloudflare, or AWS ALB. Cutover between variants is one API call (or ownstack cluster switch <c> --to=<v>) and reweights every hostname atomically.
The three tiers
| Tier | What runs there | What it does |
|---|---|---|
| Edge | DNS records, Cloudflare proxy, or AWS ALB | Solves "LB the LB". One IP/name out, N proxies in. |
| Proxy | ≥1 stack running a Caddy app owned by the cluster | One reverse_proxy block per hostname, sub-second member health checks. |
| Upstream | An OwnStack app's stack IPs, or an external HTTPS URL | The thing being load-balanced. Grouped under named variants. |
The proxy is a regular OwnStack app — you can see it in the apps list, tail its logs, restart it, SSH in, scale it. The cluster regenerates its Caddyfile when domains change and pushes per-host CLUSTER_UPSTREAMS_<HOST> env vars (then dokku ps:rebuild) when variants or upstreams change. Everything you'd do to an ordinary app still works.
Edge modes
You pick one at create time. Default is dns_rr.
| Mode | How it works | Cost | Failover |
|---|---|---|---|
dns_rr | Multi-A records via the OwnStack DNS provider. Resolvers spread connections; reconciler drops dead IPs within one cron tick. | Free | ~60 s (DNS TTL) |
cloudflare | Proxied A records via the Cloudflare API. Each proxy IP becomes an origin; Cloudflare's edge does the health check + failover. | Free (Cloudflare free tier) | Sub-second |
aws_alb | Register proxy stack instance IDs as targets on a pre-existing ALB you create once. | ~$18/mo | Seconds |
none | You're managing DNS yourself. | Free | — |
Cloudflare and ALB need credentials in the cluster's edge_config: api_token + zone_id for Cloudflare, target_group_arn + region for ALB. You set them in the cluster form.
Domains
A cluster fronts one or more hostnames. If your product spans multiple subdomains (e.g. myhavenbot.com for the marketing site and app.myhavenbot.com for the application), they all belong to the same cluster so cutover stays atomic across them.
ownstack cluster domain add api myhavenbot.com
ownstack cluster domain add api app.myhavenbot.com
ownstack cluster domain list api
ownstack cluster domain remove api app.myhavenbot.com
Each hostname becomes a separate reverse_proxy block in the proxy's Caddyfile. The control plane manages the edge records (DNS-RR, Cloudflare, ALB) per hostname under the edge mode you chose.
Variants and switching
A variant is a named group of upstreams (e.g. heroku, havenhelix, blue, green). The cluster routes to whichever variant currently has non-zero weight. Switching is one atomic reweight across all hostnames — useful for blue/green cutover, migrating off an external platform, or rolling back a bad deploy in seconds.
ownstack cluster variant add api heroku --weight=100
ownstack cluster variant add api havenhelix --weight=0
ownstack cluster switch api --to=havenhelix # all hostnames flip atomically
ownstack cluster switch api --to=heroku # roll back in seconds
Each variant has its own set of upstreams, one per hostname it serves. An upstream is either:
- An OwnStack app — resolves to the app's stack IPs (this is the v1 cluster member behavior).
- An external HTTPS URL (
scheme://host[:port], no path) — forwards to anything reachable on the public internet: a Heroku app, another OwnStack deployment, a third-party SaaS.
# Wire a variant's hostnames to external URLs:
ownstack cluster upstream add api \
--variant=heroku --hostname=myhavenbot.com \
--url=https://myhavenbot.herokuapp.com
ownstack cluster upstream add api \
--variant=heroku --hostname=app.myhavenbot.com \
--url=https://app-myhavenbot.herokuapp.com
# Or to an OwnStack app you've deployed:
ownstack cluster upstream add api --variant=havenhelix --app=myhavenbot-api
URL upstreams must be scheme://host[:port] only — Caddy's reverse_proxy directive doesn't accept paths, query strings, or fragments. The API rejects URLs with any of those at save time so you don't end up with a broken Caddyfile on the next rebuild.
Members and draining
A member is an app in the cluster. Members have three states:
- active — proxy routes new connections here.
- draining — proxy stops sending new traffic; existing connections finish. Used for graceful deploys and host maintenance.
- removed — gone from the routing list.
Drain/undrain is available inline next to each member in the cluster detail page, and on the CLI: ownstack cluster member drain <cluster> <app>.
Deploys
Two strategies:
- parallel (default) — deploy every member at once. Fast, but in-flight requests may see a brief restart.
- rolling — drain → deploy → wait healthy → undrain, one member at a time. Zero-downtime if you have ≥2 active members.
ownstack cluster deploy api --strategy=rolling
The proxy never serves zero healthy members so long as at least one is routable (active or draining).
Health checks
Two independent loops keep the cluster honest:
| Loop | Where | Speed | What it triggers |
|---|---|---|---|
| Member → proxy | Caddy on each proxy stack | Sub-second | Drop dead member from upstream pool |
| Proxy → edge | Control-plane cron (1 min) | ≤ 1 min | Drop dead proxy IP from edge config |
The member check uses Caddy's built-in health_uri directive — you configure the path / interval / timeout per cluster. Defaults are / every 5 s with a 2 s timeout.
Creating a cluster
From the UI: Clusters → New. Fill in the name, pick edge mode + proxy replicas, optionally point at an existing proxy app (or leave blank to stamp one from the default Caddy template). Add domains, variants, and upstreams after creation.
From the CLI, end-to-end:
# 1. Create the cluster
ownstack cluster create api \
--edge=dns_rr \
--proxy-count=2
# 2. Add the hostnames it fronts
ownstack cluster domain add api api.example.com
ownstack cluster domain add api app.example.com # optional second hostname
# 3. Define the variants (named backend groups)
ownstack cluster variant add api blue --weight=100
ownstack cluster variant add api green --weight=0
# 4. Wire upstreams: either an OwnStack app or an external URL
ownstack cluster upstream add api --variant=blue \
--hostname=api.example.com --app=my-rails-app
ownstack cluster upstream add api --variant=green \
--hostname=api.example.com --url=https://my-rails-app.herokuapp.com
# 5. Inspect topology
ownstack cluster status api
# 6. Cutover (atomic across all hostnames)
ownstack cluster switch api --to=green
ownstack cluster switch api --to=blue # roll back
# 7. Deploy across app-based upstreams when ready
ownstack cluster deploy api --strategy=rolling
The legacy ownstack cluster member add|drain|undrain|remove subcommands are kept as a single-variant convenience for clusters that don't need the variant model.
How it composes with the rest of OwnStack
- App migration — adding a target stack to an app's deployable list automatically fires a cluster reconcile, so the new stack joins the proxy's upstream list as soon as deploys land there.
- OS / AMI upgrade — the upgrade worker drains every cluster member deployed to the stack first, runs the swap, then undrains. ≥1 routable member is guaranteed during the swap.
- Maintenance dashboard — /maintenance shows a Clusters table with hostname, edge mode, member count, and proxy health, plus a Cluster column on the stacks list.
Bring your own proxy
The proxy app is just an app. If you want custom Caddy directives, a HAProxy controller, or some weird Envoy fork, write it as an OwnStack app and point the cluster at it with proxy_app_id. The contract is:
- Read upstreams from per-hostname env vars:
CLUSTER_UPSTREAMS_<HOST>, where<HOST>is the hostname uppercased with non-alphanumeric chars replaced by_. Example:myhavenbot.com→CLUSTER_UPSTREAMS_MYHAVENBOT_COM. Each var is a space-separated list of stack IPs and/orhttps://...URLs. - A union
CLUSTER_UPSTREAMSenv var is also set, containing every upstream across every hostname. Useful for legacy single-host proxies. - Expose
/_cluster/healthzreturning 200 when ready.
The cluster sets these env vars via dokku config:set --no-restart whenever variants or upstreams change, then runs dokku ps:rebuild so the container picks up the new values. (Earlier versions used ps:restart which silently failed for Dockerfile-based proxies.)