ingress

Caddy-backed routes, TLS, path-based dispatch.

ingress declares HTTP routing into your apps. Each block produces one Caddy route — hostname + (optional) path-prefix matching, TLS, upstream service, active health-check.

In most cases you don't write ingress directly — app generates one for you. Reach for ingress when:

  • One service backs multiple hostnames (path-based fan-out).
  • One hostname routes to multiple deployments by path.
  • You need different TLS settings per host.

The reconciliation backend is voodu-caddy.

Synopsis

ingress "scope" "name" {
  host    = "api.example.com"
  service = "api"          # defaults to ingress name
  port    = 8080           # defaults to deployment's first port

  tls {
    enabled   = true
    provider  = "letsencrypt"
    email     = "ops@example.com"
    on_demand = false
    ask       = "https://..."
  }

  location {               # repeatable
    path  = "/api/v1"
    strip = false
  }

  lb {
    policy   = "round_robin"
    interval = "10s"
  }
}

Required

  • host (string) — the hostname Caddy routes.

Optional fields

FieldTypeDefaultMeaning
servicestringingress nameThe deployment / app to forward to. Use for cross-resource routing.
portintfirst declared port on the upstreamUpstream port.

tls {} block

FieldTypeDefaultMeaning
enabledbooltrue if block presentToggle TLS. Omit the block to serve plain HTTP.
providerstring"letsencrypt""letsencrypt" (ACME HTTP-01) or "internal" (self-signed).
emailstring—ACME account email.
on_demandboolfalseOn-demand cert minting (wildcards). Requires ask.
askURL—Required when on_demand = true. Caddy calls it to authorize each new hostname.

No allow_all fallback. voodu-caddy refuses on-demand without an ask URL — it's not a config oversight, it's a deliberate safety against accidental wildcard exposure.

location {} block (repeatable)

FieldTypeDefaultMeaning
pathstringrequiredURL prefix to match. Must start with /.
stripboolfalseIf true, the prefix is stripped before forwarding.

Routes are emitted in declaration order; first match wins. path = "/" collapses to a catch-all (no prefix matching).

lb {} block

FieldTypeDefaultMeaning
policystring"round_robin"Upstream selection: round_robin, random, least_conn, ip_hash.
intervalduration—Active health-check cadence. Empty = no active probe.

When interval is set, Caddy hits the deployment's health_check = path on every replica at that cadence. Replicas that fail are dropped from the upstream pool.

tcp {} block

Turns the ingress into a TCP route with TLS. The router listens on listen, ends TLS, chooses the route by the name that the client sends in the TLS handshake (SNI), and sends plain TCP to the upstream. Many hosts share one port, one route each. Use it for a TCP protocol that is not HTTP, such as a job server.

FieldTypeDefaultMeaning
listenintthe upstream portThe public port of the router.
  • tls {} is required: SNI exists only in a TLS handshake.
  • location {} blocks are refused: TCP has no paths.
  • The router publishes the port only when the plugin is installed with it, for example VOODU_CADDY_TCP_PORTS=7419 for voodu-caddy.

Validation

  • host required (HCL parser enforces).
  • tcp {} without tls {}, or with location {} blocks → rejected at apply time.
  • tls.on_demand = true without ask → rejected by voodu-caddy at apply time. No allow_all fallback.

The HCL parser does NOT enforce:

  • location.path starting with / — the value passes through to Caddy, which handles malformed paths at config-load time.
  • lb.policy enum values — passed through to Caddy verbatim.
  • tls.enabled = true without provider — empty provider defaults to "letsencrypt".

Examples

Plain HTTP

ingress "internal" "api" {
  host    = "api.internal"
  service = "api"
  port    = 3000
}

Public TLS with Let's Encrypt

ingress "prod" "api" {
  host    = "api.example.com"
  service = "api"
  port    = 3000

  tls {
    enabled  = true
    provider = "letsencrypt"
    email    = "ops@example.com"
  }
}

Internal CA (dev / staging self-signed)

ingress "dev" "api" {
  host    = "api.dev.local"
  service = "api"
  port    = 3000

  tls {
    enabled  = true
    provider = "internal"
  }
}

On-demand wildcard with ask callback

ingress "prod" "tenants" {
  host    = "*.example.com"
  service = "app"
  port    = 3000

  tls {
    enabled   = true
    provider  = "letsencrypt"
    email     = "ssl@example.com"
    on_demand = true
    ask       = "https://app.example.com/internal/allow_domain"
  }
}

Caddy calls ask with ?domain=<requested-host> whenever a TLS handshake arrives for an unknown name. Return 2xx to authorize issuance.

Versioned API — same host, different services

ingress "prod" "api-v1" {
  host    = "api.example.com"
  service = "api-v1"
  port    = 3000
  location { path = "/api/v1" }
}

ingress "prod" "api-v2" {
  host    = "api.example.com"
  service = "api-v2"
  port    = 3000
  location { path = "/api/v2" }
}

Both ingresses share the host. Caddy serves whichever prefix matches first.

Strip-prefix forwarding for a docs subtree

ingress "prod" "docs" {
  host    = "example.com"
  service = "docs"
  port    = 80

  location {
    path  = "/docs/voodu"
    strip = true
  }
}

Request /docs/voodu/getting-started arrives at the docs upstream as /getting-started.

Active health checks

ingress "prod" "api" {
  host    = "api.example.com"
  service = "api"
  port    = 8080

  lb {
    interval = "5s"
  }
}

Caddy probes / (or whatever the deployment declares as health_check) on each replica every 5s and drops failing ones from the pool.

TCP with TLS, chosen by SNI

ingress "cloud" "paris-roma" {
  host    = "paris--roma.eu-de-1.anway.clowk.in"
  service = "paris-roma"
  port    = 7419

  tcp {
    listen = 7419
  }

  tls {}
}

A client connects with TLS to paris--roma.eu-de-1.anway.clowk.in:7419. Another ingress with another host can use the same listen port.

Trade-offs

One host per ingress. host is a scalar — no hosts = [...] form. Multi-host workloads use multiple ingress blocks (which can share the same service).

No rewrite, no headers, no middleware. voodu-caddy supports path matching and strip prefix removal — nothing else for now. Custom HTTP transformations belong in your app.

tls {} defaults to enabled. Declaring a bare tls {} block flips both enabled and provider = "letsencrypt". To serve plain HTTP, omit the block entirely.

on_demand needs ask. No allow_all fallback. Your app endpoint decides which subdomains are real customers — that's what keeps the wildcard safe.

/load is atomic. Every ingress change rewrites the full Caddy config from disk. There's no partial reload — atomic-replace ensures consistency across all routes.

State survives uninstall. voodu-caddy keeps /data (certs) and /routes/*.json (route definitions) on uninstall. Reinstalls pick up existing certs without re-issuing.

Path matching is glob-style. Caddy needs both the exact prefix AND the prefix/* form to match. voodu-caddy emits both automatically — don't add the wildcard yourself.

Plugin scope. Routes are reconciled by voodu-caddy, which runs as a docker container on the voodu0 network. Deployments and apps join voodu0 automatically, so upstream service names resolve via Docker DNS inside the Caddy container.

See also

  • app — sugar that generates this ingress for you
  • voodu-caddy plugin — the backend that reconciles routes
  • probes — readiness gating that interacts with active HC

On this page