OTP-native edge proxy and blue-green traffic switcher for Elixir deployments.
Note
The gatehouse package currently published on Hex as 0.0.1 is a placeholder
that reserves the package name. Use this repository as the canonical source
while the first usable Gatehouse release is prepared.
Gatehouse is a BEAM-native application router for HostKit-style deployments. The proxy runs as a stable Erlang node at the edge while deploy tooling orchestrates releases over SSH.
The public Hex package is intentionally a placeholder for now:
gatehouse 0.0.1reserves the package name and is not a usable runtime.- The real Gatehouse code currently lives in this public repository.
- HostKit source deployments should clone this repository directly until the first usable Gatehouse package is released.
- The real Hex release is blocked on upstream Livery dependency resolution:
the current Hex
livery 0.4.xreleases are unsatisfiable because transitive wire dependencies still pin olderhackney,webtransport, andh2versions while Livery itself requires newer ones. - Gatehouse temporarily depends on a Livery Git fork while the upstream fixes needed for a satisfiable Hex dependency set are pending.
Use a Git dependency while this status remains:
{:gatehouse, github: "elixir-vibe/gatehouse", branch: "master"}The package is intentionally small and OTP-first:
- The application callback starts the supervision tree.
Gatehouse.RuntimeConfigcontains an ordered canonical graph of typed servers, routes, matchers, and handlers.Gatehouse.RouteTablecompiles and atomically publishes the serving subset in ETS.Gatehouse.Serviceis a:gen_statemprocess per prepared service revision.Gatehouse.Controlis the distribution-friendly API;Gatehouse.ConfigManagerserializes every accepted mutation.Gatehouse.Targetmodels one backend target and request counts.Gatehouse.HealthCheckvalidates targets before activation.Gatehouse.Storeprovides atomic ETF persistence helpers.Gatehouse.ListenerManagerreconciles supervised per-bindGatehouse.LiveryListenerprocesses.Gatehouse.Backend.Gunperforms pooled backend requests and streams request/response bodies through Gun.Gatehouse.WebSocketProxybridges Livery WebSocket upgrades to backend Gun WebSocket sessions.Gatehouse.ACME.Providerdefines the ACME adapter boundary.Gatehouse.ACME.RenewalSchedulerreconciles accepted renewal jobs, persists renewed certs, and asks the listener manager to refresh TLS.
See docs/architecture.md for runtime invariants and the
canonical matcher/handler route model.
A remote deployer can call the edge node through Erlang distribution:
:rpc.call(:"gatehouse@host", Gatehouse.Control, :deploy, [spec], 60_000)Example deploy spec:
%{
service: "my_app",
hosts: ["example.com"],
target_id: "green-20260608-1",
target_url: "https://origin.internal:4001",
protocol: :auto,
tls: [verify: :verify_peer, ca_certfile: "/etc/gatehouse/origin-ca.pem"],
health_path: "/up",
health_timeout: 5_000,
drain_timeout: 30_000,
metadata: %{version: "20260608-1"}
}Imperative deploys are compiled into a new Gatehouse.RuntimeConfig revision and
share the same serialized activation path as full configuration reloads.
Use a minimal Caddy-like Elixir DSL built on
elixir-vibe/dsl. There is no root wrapper:
import Gatehouse.Config
state "/var/lib/gatehouse/state.etf"
http port: 80
https port: 443,
cert: "/etc/gatehouse/certs/fallback.crt",
key: "/etc/gatehouse/certs/fallback.key"
service :my_app do
host "example.com"
host "www.example.com"
target :blue, "http://127.0.0.1:4000/internal/api?edge=primary", active: true
target :green, "http://127.0.0.1:4001/internal/api?edge=secondary"
# target :secure, "https://origin.internal:8443", protocol: :auto,
# tls: [verify: :verify_peer, ca_certfile: "/etc/gatehouse/origin-ca.pem"]
balance :round_robin
forwarding trusted_proxies: ["10.0.0.0/8", "2001:db8::/32"]
timeouts connect: 5_000, request: 30_000, response: 30_000, idle: 60_000
health "/up", timeout: 5_000, interval: 1_000
drain 30_000
tls :auto
endA host inside a service remains shorthand for a host-only reverse-proxy route.
For ordered matching and multiple upstream services behind one authority, declare
top-level routes. The first route whose matchers all succeed is selected:
service :api do
target :primary, "http://127.0.0.1:4100", active: true
end
service :frontend do
target :primary, "http://127.0.0.1:4200", active: true
end
route :health do
host "example.com"
path "/health"
response_header :set, "cache-control", "no-store"
respond 200, "ok", headers: [{"content-type", "text/plain"}]
end
route :legacy do
host "example.com"
path "/old"
redirect "/new", 308
end
route :api do
host "example.com"
path_prefix "/api"
method [:get, :post]
header "x-edge-mode", ["public", "partner"]
query "version", ["1", "2"]
request_header :set, "x-gatehouse-route", "api"
response_header :set, "x-served-by", "gatehouse"
reverse_proxy :api
end
route :fallback do
host "example.com"
reverse_proxy :frontend
endpath matches exactly, while path_prefix observes path-segment boundaries.
Header names and methods are case-normalized; configured values are alternatives.
Query matching preserves duplicate and blank keys while decoding only the matcher
view. The untouched raw query remains available to upstream request forwarding.
Explicit routes run before compatibility routes generated by service-level hosts.
Handlers execute in declaration order. request_header, response_header, and
rewrite continue the chain; respond, redirect, and reverse_proxy are
terminal and must be last. Every route has exactly one terminal handler. A
rewrite replaces the complete upstream path and optionally the raw query, for
example rewrite "/internal/health", query: "source=edge". Static and redirect
responses do not acquire an upstream service lease; proxy accounting starts only
when a reverse-proxy terminal is selected.
Point the release at that file with ordinary application config:
config :gatehouse, config_path: "/etc/gatehouse.exs"Static configuration is validated and prepared as an immutable
Gatehouse.RuntimeConfig. Gatehouse.ConfigManager serializes reloads and
advances a monotonic revision only when a candidate is activated. Gatehouse
starts revision-specific candidate service processes and builds their complete
route set off-path. One ETS table-pointer update activates both for new requests,
so requests see either the previous routes or the replacement rather than a
partially applied route table. Previous service processes remain available until
tracked requests drain. Persisted version 2 snapshots retain explicit route
specifications as well as service state; version 1 snapshots remain readable.
Gatehouse strips client-supplied forwarding identity unless the immediate peer
matches a service's trusted_proxies. It emits RFC 7239 Forwarded plus
X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Port, and
X-Forwarded-Proto, preserves the original Host, and removes fixed and
Connection-declared hop-by-hop headers. When an ingress adapter cannot report
the socket peer, Gatehouse treats the request as untrusted and omits
X-Forwarded-For; Livery peer metadata support is tracked in
benoitc/livery#78.
An HTTP target URL may include a base path and query. Gatehouse prefixes the
incoming path with the target path using one boundary slash, then appends the
incoming raw query after the target query without decoding, re-encoding, or
deduplicating keys. For example, /internal/api?edge=primary plus
/users?limit=10 becomes /internal/api/users?edge=primary&limit=10. The same
request-target construction is used for ordinary HTTP and WebSocket upgrades.
Fragments and URL userinfo are rejected because they cannot be applied safely
to an upstream request.
HTTPS targets default to ALPN negotiation with HTTP/2 preferred and HTTP/1.1
fallback. protocol: :http1 or protocol: :http2 forces one TLS protocol;
protocol: :h2c selects cleartext HTTP/2 prior knowledge for an http URL.
Plain HTTP defaults to HTTP/1.1. HTTP/2 connections are shared for concurrent
streams, while pool keys isolate protocol and TLS policies. Health checks use
the same prepared transport policy as serving requests.
Upstream TLS verifies the certificate chain and URL hostname against the system
trust store by default. tls: [ca_certfile: path] installs a custom PEM trust
bundle, while tls: [server_name: name] overrides both SNI and the verified
certificate identity for IP-address or alternate-origin targets. Every custom
trust certificate is fully decoded with OTP :public_key before the bundle is
fingerprinted during candidate preparation. The explicit
verify: :verify_none escape hatch is available for controlled development
only.
Backend timeout policies are prepared per service. connect bounds connection
pool checkout and establishment, request bounds each streamed request-body
read, response bounds the wait for upstream response headers, and idle
bounds gaps between response body events and WebSocket activity. Timeouts before
response headers produce 504 Gateway Timeout; unavailable local target or
SafeRPC pool state produces 503 Service Unavailable; other upstream transport
and protocol failures produce 502 Bad Gateway. A timeout after streaming
headers have been sent terminates the stream because its HTTP status can no
longer be changed. Upstream response trailers are forwarded after streamed
bodies, with fields forbidden in trailers removed. A downstream disconnect
cancels the selected Gun stream or SafeRPC task and releases revision drain
accounting exactly once, including races with normal completion and failure.
WebSocket sessions retain their selected revision until the upgraded session
terminates, so a reload cannot stop their previous service prematurely.
HTTPS listener cert/key paths are retained; Gatehouse.ListenerManager.refresh_tls/0 asks each HTTPS listener to reread them. Successful ACME renewals call this automatically. When acme is configured and any service uses tls :auto, the HTTPS listener automatically installs an Erlang/OTP :ssl SNI callback backed by the ACME certificate store. Reloads reuse unchanged network binds; unsafe same-bind option replacements are rejected rather than introducing downtime.
Set :persistence_path to restore saved service state on boot and persist after
deploys:
config :gatehouse, persistence_path: "/var/lib/gatehouse/state.etf"Gatehouse.ACME.Provider.ExAcme is the primary Elixir ACME adapter. It uses
ex_acme for account registration, HTTP-01 authorization, CSR finalization,
certificate fetch, and revocation. HTTP-01 tokens are published through
Gatehouse.ACME.ChallengeStore, which the Livery handler serves before proxy
routing.
Static config now turns tls :auto services into renewal jobs automatically:
acme email: "ops@example.com",
cert_directory: "/var/lib/gatehouse/certs",
account_directory: "/var/lib/gatehouse/acme"
service :my_app do
host "example.com"
host "www.example.com"
tls :auto
endThe generated job stores certificates under cert_directory, writes aliases for
all service hosts, and persists the ACME account key under account_directory so
renewals reuse the same account. SNI lookup uses the same certificate store, so a
certificate issued for example.com and www.example.com can be selected by
either hostname.
Pebble integration coverage is opt-in because it needs a local Pebble server:
scripts/pebble_integration_test.shLike systemdkit, the script copies the project into the Lima VM named
systemd-test, builds Pebble from source with Go if needed, starts Pebble with
PEBBLE_VA_ALWAYS_VALID=1, and runs:
GATEHOUSE_PEBBLE=1 GATEHOUSE_PEBBLE_EXTERNAL=1 mix test test/gatehouse/acme_pebble_integration_test.exsPhoenix apps can add Gatehouse as a dev dependency and run their dev server behind a stable local HTTPS URL. Until the first usable Hex package is released, use the public Git repository:
# mix.exs in your Phoenix app
def deps do
[
{:gatehouse, github: "elixir-vibe/gatehouse", branch: "master", only: :dev, runtime: false}
]
endmix gatehouse.trust
mix gatehouse.phx
# => https://my-app.localhost:4443 -> http://127.0.0.1:<random-port>mix gatehouse.phx chooses a free backend port, exposes it as PORT, starts a
local Gatehouse HTTPS proxy, registers the .localhost host, and then runs
mix phx.server. Regular Phoenix requests, static assets, and LiveView
WebSockets are proxied through the same HTTPS origin. For custom commands use:
mix gatehouse.run -- mix phx.server
mix gatehouse.run --open -- mix phx.server
mix gatehouse.run --host admin.localhost --proxy-port 443 -- mix phx.server
mix gatehouse.run --no-tls -- mix phx.serverMake sure your Phoenix endpoint reads PORT in dev, for example:
config :my_app, MyAppWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
check_origin: ["https://my-app.localhost:4443"]The development CA and host certificates live under ~/.gatehouse/dev_certs by
default. mix gatehouse.trust creates the CA and prints OS-specific trust-store
instructions; it does not run sudo automatically. See
docs/phoenix-dev.md for details and troubleshooting.
This project was created with Igniter and VibeKit:
mix igniter.new gatehouse --sup --install vibe_kit --yesRun checks with:
mix ci- Resolve upstream Livery/Barrel MCP dependency blockers and publish the first usable Gatehouse Hex release.
- Add richer telemetry dashboards/examples.
- Add load and stress test scenarios for HTTP, WebSocket, blue-green switching, and ACME challenge routing.
- Add load-balancing policies beyond one active target.
- Add full multi-target runtime load balancing beyond the config shape.