Manual Deploy Load Balancer

Load Balancer

elements man deploy/load-balancer Read as markdown

TLS, DNS, routing, and the certificate lifecycle Elements manages for you.

Every Elements deploy machine runs its own load balancer. It is part of Elements, starts alongside the app, and gives you two things automatically: SSL via Let's Encrypt for every configured domain, and request balancing across every machine in the environment. There is no extra software to install, no separate appliance to put in front of the cluster, and no separate config file to maintain. The load balancer reads the same config.jsoc your app reads, and it auto-reloads on new builds as needed, so changes to domains, certificates, or services propagate without restarting anything.

Because every machine runs the same load balancer and knows about every other machine in the environment, you can point your DNS at one of your machines, several of them, or all of them. Whichever box receives a request will distribute it across the cluster.

What it does:

  • HTTP listener on port 80 (configurable). Serves ACME challenges and 301-redirects to HTTPS when TLS is on.
  • HTTPS listener on port 443 (configurable). Terminates TLS, then proxies to the app or service backend.
  • Auto SSL via ACME / Let's Encrypt. Certificates issued and renewed automatically for every configured domain.
  • Static SSL: bring-your-own certificates served by SNI match (overrides ACME for those hosts).
  • Host routing: dispatches by Host header to the app or a named service.
  • Round-robin: balances requests across all machines in the environment.
  • Failover: retries other machines when a backend fails. Serves the maintenance page only when every backend is unreachable.
  • Redirects: 301 from configured source hosts to destination hosts before any service routing.
  • Maintenance: serves a static maintenance page when deploy.maint = true.

The load balancer hot-swaps its handler atomically when the project config changes. Adding a domain, flipping ssl, or rotating a static cert takes effect without dropping connections.

Auto SSL (acme)

When ssl: true (the default when domain is set) and the machine has a publicIp, the LB enables ACME via Let's Encrypt. On the first HTTPS handshake for a configured host, the LB negotiates a certificate using the HTTP-01 challenge served on port 80. Subsequent handshakes use the cached certificate. Renewal happens automatically before expiry.

Set up:

  1. Configure the domain in deploy.domain (and any service domains).
  2. Point DNS at the machine's public IP.
  3. Open ports 80 and 443 to the public internet.

The first HTTPS request takes a few seconds while the certificate is issued. After that, it's cached on the machine and renewed automatically.

ACME is enabled when deploy.ssl is true (default if domain is set), at least one domain is configured, and the machine has a publicIp. When any condition is missing, TLS is disabled and the LB serves cleartext on port 80. Useful for internal-only deployments or local testing.

Static TLS Certs

To override ACME for specific hosts, drop a cert/key pair on disk and reference them in deploy.tlsCerts:

deploy: {
  tlsCerts: {
    "*.example.com":   { cert: "config/tls/wildcard.crt", key: "config/tls/wildcard.key" },
    "example.com":     { cert: "config/tls/wildcard.crt", key: "config/tls/wildcard.key" },
    "api.example.com": { cert: "config/tls/api.crt",      key: "config/tls/api.key" },
  },
}

When the LB sees a matching SNI hostname, it serves the static cert instead of talking to ACME. Match priority:

  1. Exact match (api.example.com).
  2. Single-label wildcard (*.example.com).

ACME continues to operate for any host without a static entry. The two modes coexist per-host.

Cert paths resolve relative to the project root. Keep keys out of version control (mode 600; add to .gitignore).

DNS

Point DNS at the public IP of one or more machines. For a small app, point at production0 and stop:

A    example.com    <production0 publicIp>

For redundancy or scale, point at multiple machines:

A    example.com    <production0 publicIp>
A    example.com    <production1 publicIp>
A    example.com    <production2 publicIp>

DNS round-robin distributes incoming requests across machines. The LB on each machine then round-robins to all the other machines, so a request that hits the busiest machine still gets balanced. Multiple A records also give you basic failover: if one machine is unreachable at the network level, the resolver falls back to another.

For finer-grained failover, use a managed DNS provider with health checks (Cloudflare, Route 53). Elements doesn't need it, but you can layer it on.

Routing

The LB looks at the Host header on each request:

  1. Strip the port.
  2. If the host matches a redirects entry, return 301 to the destination host.
  3. If the host matches a service's domains, proxy to that service's port (round-robined across machines).
  4. Otherwise, proxy to the app backend (default port 4000, round-robined across machines).
deploy: {
  services: [
    {
      name: "api",
      port: 8080,
      domains: ["api.example.com", "v1.api.example.com"],
      bin: "services/api/bin/api",
    },
  ],
}

Round-Robin and the public/private Distinction

Public requests (source IP outside the private network) are round-robined across every machine in the environment that has a privateIp. Each LB picks a different machine on each request via an atomic counter.

Requests from a private IP (other LBs forwarding to this one) are served locally instead of being round-robined again. This prevents infinite loops where two LBs forward to each other indefinitely.

Failover

When a backend connection fails (refused, timeout), the LB tries the next machine. It cycles through every backend before giving up. Only when every backend is unreachable does the LB serve the maintenance page.

A single machine going down doesn't take traffic out. Add machines for headroom; the cluster heals itself per request.

Maintenance Mode

deploy: {
  maint: true,
}

When maint is true, the LB serves a static maintenance page on every request instead of proxying. Use during heavy data migrations or planned outages.

The maintenance page is also served as the fallback when no backend is reachable.

CDN

A CDN isn't required. Asset URLs are content-hashed and the LB serves them with Cache-Control: immutable, so the browser caches them indefinitely after the first fetch. The origin handles asset traffic well at small to medium scale.

When you do want a CDN (CloudFront, Cloudflare, Fastly, Bunny, etc.) the integration is one config setting:

  1. Create a distribution at the CDN. Point it at the LB's public IP or hostname as the origin. The CDN gives you a hostname back, e.g. d1234abcd.cloudfront.net.

  2. Set build.assetUrl in config.jsoc:

    {
      build: {
        assetUrl: env("ASSET_URL", ""),
      },
    }
    
    # production.env
    ASSET_URL=https://d1234abcd.cloudfront.net
    
  3. Deploy.

That's the entire integration. The build rewrites every emitted asset reference in your pages to use that base URL. Browsers fetch from the CDN; the CDN fetches from origin once on cache miss, then serves from edge.

In development, leave ASSET_URL empty. Elements emits relative URLs that hit the LB directly. There is nothing to switch on between development and production beyond the environment variable.

Configure the CDN to forward the Host header so the LB's host routing still works for any non-asset request that hits the same distribution.