Skip to content

Repository files navigation

Gatehouse

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.

Package status

The public Hex package is intentionally a placeholder for now:

  • gatehouse 0.0.1 reserves 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.x releases are unsatisfiable because transitive wire dependencies still pin older hackney, webtransport, and h2 versions 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"}

Current architecture slice

The package is intentionally small and OTP-first:

  • The application callback starts the supervision tree.
  • Gatehouse.RuntimeConfig contains an ordered canonical graph of typed servers, routes, matchers, and handlers.
  • Gatehouse.RouteTable compiles and atomically publishes the serving subset in ETS.
  • Gatehouse.Service is a :gen_statem process per prepared service revision.
  • Gatehouse.Control is the distribution-friendly API; Gatehouse.ConfigManager serializes every accepted mutation.
  • Gatehouse.Target models one backend target and request counts.
  • Gatehouse.HealthCheck validates targets before activation.
  • Gatehouse.Store provides atomic ETF persistence helpers.
  • Gatehouse.ListenerManager reconciles supervised per-bind Gatehouse.LiveryListener processes.
  • Gatehouse.Backend.Gun performs pooled backend requests and streams request/response bodies through Gun.
  • Gatehouse.WebSocketProxy bridges Livery WebSocket upgrades to backend Gun WebSocket sessions.
  • Gatehouse.ACME.Provider defines the ACME adapter boundary.
  • Gatehouse.ACME.RenewalScheduler reconciles 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.

Runtime configuration

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
end

A 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
end

path 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"

ACME

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
end

The 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.sh

Like 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.exs

Phoenix local HTTPS DX

Phoenix 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}
  ]
end
mix 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.server

Make 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.

Development

This project was created with Igniter and VibeKit:

mix igniter.new gatehouse --sup --install vibe_kit --yes

Run checks with:

mix ci

Near-term roadmap

  1. Resolve upstream Livery/Barrel MCP dependency blockers and publish the first usable Gatehouse Hex release.
  2. Add richer telemetry dashboards/examples.
  3. Add load and stress test scenarios for HTTP, WebSocket, blue-green switching, and ACME challenge routing.
  4. Add load-balancing policies beyond one active target.
  5. Add full multi-target runtime load balancing beyond the config shape.

About

OTP-native edge proxy and blue-green traffic switcher for Elixir deployments

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages