Configuration

Shepherd reads its settings from a YAML file and from SHEPHERD_* environment variables, which override it. In a chart install the non-secret settings come from the chart’s config: block, rendered into a ConfigMap mounted at /etc/shepherd/shepherd.yaml, and the secrets below from a Kubernetes Secret; in local development, from your shell (and ./shepherd.yaml if present). Only two settings are required — the server refuses to start without them — and the rest matter only for the setups they name.

The secret Shepherd reads

In a chart install these are keys of the Kubernetes Secret the chart reads (shepherd-secrets by default, or override existingSecret). Running locally, they are plain environment variables.

KeyRequired?Description
SHEPHERD_DATABASE_URLYesPostgreSQL connection string
SHEPHERD_SECURITY_ENCRYPTION_KEYYesBase64-encoded 32-byte key encrypting stored credentials at rest. Keep it — a replacement key cannot decrypt what the old one wrote
SHEPHERD_BOOTSTRAP_ADMIN_PASSWORDRecommendedPassword for the administrator created on first boot. Read only while the users table is empty. Leave it unset and the account is created as admin/admin, which can do nothing until the password is changed
SHEPHERD_BOOTSTRAP_ADMIN_LOGINNoLogin of that first administrator; defaults to admin
SHEPHERD_OIDC_CLIENT_SECRETChart-configured SSO onlyOmit it to configure SSO from the UI instead
SHEPHERD_GRAPH_CLIENT_SECRETEntra + Graph lookup onlyNot used by any other provider

See deploy/helm/shepherd/values.yaml for the full set of chart options, including networkPolicy and autoscaling. The chart has no user-seeding options of its own — the bootstrap admin above is the only account a fresh install creates. (shepherd dev seed, which additionally creates local editor/viewer accounts, is a dev-only CLI command outside the chart; see internal/cli/dev.go.)

Where these values come from is a separate decision: see Secrets for supplying them yourself or having them generated, and Database for the connection string.

The encryption key is not rotatable

Keep SHEPHERD_SECURITY_ENCRYPTION_KEY. It encrypts git credentials and the OIDC client secret at rest. A new key on the next boot cannot decrypt what the old one wrote, and Shepherd has no way to tell you which stored secret it has just lost — it simply fails to use them. Generate it once, store it where you store your other secrets, and back it up with them.

The first administrator

SHEPHERD_BOOTSTRAP_ADMIN_LOGIN and SHEPHERD_BOOTSTRAP_ADMIN_PASSWORD are read only while the users table is empty, on first boot. Set the password and the account is yours. Leave it unset and the account is created as admin/admin with a forced password change, which blocks every request except the change itself — so an unattended install cannot quietly sit on a default credential. After first boot, accounts are managed at Admin → Users; see Users and teams.

Client addresses behind an ingress: server.trusted_proxies

Shepherd throttles local sign-in per client address as well as per username. By default it takes that address from the TCP connection and trusts no forwarding header, because any client can send X-Forwarded-For, X-Real-IP or True-Client-IP. Behind an ingress controller or gateway, though, every connection comes from the proxy, so every browser shares one per-IP bucket: about 60 failed sign-ins from anyone, signed in or not, block local sign-in for everyone until they stop. OIDC sign-in is not throttled this way and is unaffected.

server.trusted_proxies (config.server.trusted_proxies in the chart; SHEPHERD_SERVER_TRUSTED_PROXIES, comma-separated) lists the CIDRs your proxies connect from. When a connection comes from one of them, and only then, Shepherd reads X-Forwarded-For right to left, skips entries that are themselves trusted, and uses the first one that is not. Entries a client adds itself sit to the left of what your proxy writes, so they are never reached. X-Real-IP and True-Client-IP are never read. An entry that does not parse as a plain address (one carrying a port, for instance) stops the walk and the proxy’s own address is used — the shared bucket, never a client-chosen one. Default: empty.

A bare address is taken as a single host. A /0, an IPv4 prefix shorter than /8 or an IPv6 prefix shorter than /16 stops Shepherd at startup. This only works if two things hold, and the next three sections are about them: the address your proxy records is the client’s real one, and nothing else can connect from inside the list.

What your proxy writes

ingress-nginx by default sets X-Forwarded-For to the address it saw ($remote_addr), replacing whatever the client sent: one entry per request, and the client cannot influence it. Leave use-forwarded-headers, enable-real-ip and use-proxy-protocol off unless you also narrow proxy-real-ip-cidr to your real load balancer. Its default is 0.0.0.0/0, so with any of those on, ingress-nginx takes the client address from whatever the client sent, and the “address it saw” becomes client-chosen before it reaches Shepherd.

NGINX Gateway Fabric appends the address it saw ($proxy_add_x_forwarded_for). The same caution applies to its NginxProxy rewriteClientIP setting: with loose trustedAddresses, NGF accepts a client-supplied address as the one it saw.

If an HTTP load balancer in front of the proxy adds its own X-Forwarded-For entry, its addresses belong in the list too.

values.yaml — ingress-nginx
config:
  server:
    # The IP pool the ingress-nginx controller pods get addresses from.
    # ingress-nginx SETS X-Forwarded-For to the address it saw; keep
    # use-forwarded-headers / enable-real-ip / use-proxy-protocol off
    # unless proxy-real-ip-cidr names only your load balancer.
    trusted_proxies:
      - 10.244.0.0/16
values.yaml — Gateway API (NGINX Gateway Fabric)
config:
  server:
    # The IP pool the Gateway's NGINX data-plane pods get addresses from.
    # NGF APPENDS the address it saw to X-Forwarded-For; keep NginxProxy
    # rewriteClientIP off unless trustedAddresses names only your load balancer.
    trusted_proxies:
      - 10.244.0.0/16

The proxy must see the real client address

When you trust the pod CIDR, the proxy’s Service needs externalTrafficPolicy: Local (or PROXY protocol, with the proxy’s own trust list narrowed to your load balancer as above). With the default Cluster, kube-proxy rewrites the source of traffic that lands on a different node to that node’s address. On overlay networks — Calico in IPIP or VXLAN mode, flannel VXLAN, and the kind dev stack this repository ships — that address is the node’s tunnel address, taken from the pod IP pool, so it falls inside a pod-CIDR trusted_proxies and is skipped as a trusted hop. With NGINX Gateway Fabric (which appends) the next entry to the left is whatever the client sent, so the client chooses its own bucket again. With ingress-nginx (which replaces) everyone arriving through that node shares one bucket.

Everything in the list can claim any address

Every address inside trusted_proxies is believed when it names a client. Trust the whole pod CIDR and any pod in the cluster can claim any client address — by connecting to Shepherd directly with a forged X-Forwarded-For, or by going through the ingress, whose own address is trusted and skipped. Narrow it where you can:

  • List only the IP pool the ingress or gateway namespace draws from, if your CNI assigns per-namespace pools (Calico IP pools with a namespace selector, for example).
  • Restrict who can reach Shepherd’s pods with a NetworkPolicy. The chart’s networkPolicy.enabled policy admits every namespace today (namespaceSelector: {}), so it does not do this; add your own policy selecting the Shepherd server pods that allows port 8080 only from the proxy’s namespace and from wherever your in-cluster collectors poll from, and the metrics port from your Prometheus.
  • If the ingress controller runs with hostNetwork: true, its pods connect from the node addresses, so the list needs the node CIDR instead of the pod CIDR, and everything else on those nodes can then claim any address.

Get the list wrong the other way — a CIDR that does not contain the proxy — and Shepherd keeps using the proxy’s address: no spoofing, only the shared bucket.

Single sign-on

One setting decides where SSO configuration comes from: oidc.issuer. Set it in your chart values and the chart owns SSO; leave it unset and an app admin configures a provider from the UI instead. Single sign-on covers both paths and what each one costs you.