Skip to content

Config as code

iderex edited this page Sep 13, 2026 · 3 revisions

Config as code

On the 5.0 line (Jellyfin 12), the settings are five tabs. This page describes the build Latest points at, where every setting is on one page. On the 5.0 line the plugin's entry in the plugin list opens Overview, which shows a card per provider, the SSO-only state and what to do next, and carries no setting of its own; the other four tabs are Providers (both provider workspaces, their editors, the login-button fields and the Configuration check), Accounts (the linked-accounts panel and the account-link export and import), Policies (the provisioning-profile editor) and Server (the configuration export and import, the login-page buttons switch and single logout). So where this page says the settings page, a provider is on Providers and a provisioning profile on Policies; the SSO-only settings it says are changed on the page are on Overview.

Two sources let a deployment describe its identity providers outside the settings page: a JSON document mounted into the container, and environment variables. Both are read once while the plugin is being constructed, so they apply at server start and nowhere else.

An installation that configures neither behaves exactly as one built before these existed. Nothing is read, nothing is written and nothing is logged.

The document

There is no separate schema. The document is the one the admin API already produces and accepts, so the quickest way to a correct file is to configure one provider on the settings page and export it:

GET  /sso/Config/Export
POST /sso/Config/Import

It has two members. FormatVersion is 1 today, and a document carrying any other value is refused rather than partly applied. Configuration holds the two provider maps, OidConfigs and SamlConfigs, keyed by the provider name you chose.

Field names are matched without regard to case. A member the model does not know is ignored in the file, which is worth knowing because a misspelled field name is silently a no-op there. The environment source is stricter and refuses one.

Every provider field, and what it does, is on the Hardening and Options Reference page. This page describes the sources, not the fields.

A working example

/run/sso/providers.json:

{
  "FormatVersion": 1,
  "Configuration": {
    "OidConfigs": {
      "keycloak": {
        "OidEndpoint": "https://id.example.org/realms/media/.well-known/openid-configuration",
        "OidClientId": "jellyfin",
        "OidSecretFile": "/run/secrets/keycloak_client_secret",
        "Enabled": true,
        "EnableAuthorization": true,
        "Roles": ["media"],
        "AdminRoles": ["media-admin"],
        "EnableAllFolders": true,
        "RequirePkce": true
      }
    },
    "SamlConfigs": {}
  }
}

and the compose service that mounts it:

services:
  jellyfin:
    image: jellyfin/jellyfin
    environment:
      JELLYFIN_SSO_CONFIG_FILE: /run/sso/providers.json
    volumes:
      - ./sso/providers.json:/run/sso/providers.json:ro
    secrets:
      - keycloak_client_secret

secrets:
  keycloak_client_secret:
    file: ./secrets/keycloak_client_secret

JELLYFIN_SSO_CONFIG_FILE is the only setting that names the file, and it is an environment variable rather than a field on the settings page on purpose: the source that manages the stored configuration cannot itself be stored there, because a fresh install has no configuration yet.

The same example as environment variables

The second source is the same document addressed by path, with __ between the steps. It ships alone, so a deployment that sets variables and mounts no file is fully configured.

JELLYFIN_SSO_CONFIG__OidConfigs__keycloak__OidEndpoint=https://id.example.org/realms/media/.well-known/openid-configuration
JELLYFIN_SSO_CONFIG__OidConfigs__keycloak__OidClientId=jellyfin
JELLYFIN_SSO_CONFIG__OidConfigs__keycloak__OidSecret=...
JELLYFIN_SSO_CONFIG__OidConfigs__keycloak__Enabled=true
JELLYFIN_SSO_CONFIG__OidConfigs__keycloak__EnableAuthorization=true
JELLYFIN_SSO_CONFIG__OidConfigs__keycloak__Roles__0=media
JELLYFIN_SSO_CONFIG__OidConfigs__keycloak__AdminRoles__0=media-admin
JELLYFIN_SSO_CONFIG__OidConfigs__keycloak__EnableAllFolders=true
JELLYFIN_SSO_CONFIG__OidConfigs__keycloak__RequirePkce=true

A step names a field (in any case), a provider name (taken verbatim, so a provider may be called almost anything), or a list index counting from zero. Field names are resolved against the configuration model itself, so a field added to the plugin is addressable the day it arrives.

Four rules bite here and each one refuses the whole source rather than skipping the variable, because a half-applied provider is worse than a server that starts on what it already had:

  • Only OidConfigs and SamlConfigs are reachable. JELLYFIN_SSO_CONFIG__EnableRateLimit and the SSO-only settings are refused, and are changed on the settings page.
  • A variable naming no field is refused. Nothing completes these names, and a typo that was quietly dropped would leave a provider carrying half of what you asked for.
  • A list given index 2 without 0 and 1 is refused rather than deserialized with holes in it.
  • A value that is not the field's type is refused, naming the variable.

A secret is written out in full here, unlike in the file. One of the two reference forms the file takes is an environment variable, so a reference from a variable to another variable would buy nothing.

One shape is out of reach: a provider whose name contains a double underscore cannot be addressed, because the name would be indistinguishable from two steps. Configure that provider from the file or from the settings page.

Precedence

Read this table as a merge by provider, never as a replace of the whole configuration.

Where a provider is named What happens at start
Environment only It wins. The environment is applied after the file.
File only It wins over what is stored.
Both The environment wins the whole provider. A field the environment leaves out comes back at its default, not at what the file said.
Stored only Left exactly as it is. Neither source removes a provider.
Neither, nothing set Nothing is read, nothing is written.

The part that catches people out is inside a provider a source names. A provider is declared WHOLE. A field the source omits comes back at its default, not at what was stored, so declare every field of a provider your file or your environment owns.

Two things survive the merge anyway, because the server owns them: the account link maps and issuer bindings, and a secret left blank, which keeps the stored one rather than clearing it.

There is one deliberate exception, and it is the same one a settings-page edit carries. Changing an EXISTING OpenID provider's discovery endpoint or client id is treated as a repoint to a possibly different identity provider, so that provider's account links, issuer bindings and stored secret are cleared. It is what stops a foreign identity provider inheriting the old one's accounts. Editing an endpoint in a mounted file therefore unlinks every account on that provider, exactly as editing it on the page does, and a file edit is the quieter of the two acts.

The two sources are applied separately rather than merged into one document, so an environment you got wrong leaves an accepted file standing instead of taking it down as well.

Secrets are references, not values

In the file, a secret is never written out. Three fields carry one, and they are exactly the three the API refuses to export: OidSecret on an OpenID provider, SamlSigningKeyPfx and SamlRolloverSigningKeyPfx on a SAML one.

Each takes one of two reference forms, spelled as the field name plus a suffix:

  • <field>Env names an environment variable holding the value, which is how a compose file or a Kubernetes env block hands one to a container.
  • <field>File names a path to read, which is the docker-secret and projected volume habit.
"OidSecretEnv": "KEYCLOAK_CLIENT_SECRET"
"OidSecretFile": "/run/secrets/keycloak_client_secret"

A file's content is trimmed, because a secret file written by a shell redirect or a projected volume ends in a newline and a client secret carrying one fails at the token endpoint with an error nobody traces back to the file. The cost is that a secret whose real value begins or ends with whitespace has to use the variable form.

Writing the secret into the document instead is refused, not warned about. The file is an artefact a deployment keeps in version control, so an inline secret is a secret in a repository, in a backup and in every image layer that copied it, and a warning at boot arrives long after that.

Refused as well: naming both forms on one field, a variable that is not set, a file that cannot be read or that holds nothing, and a secret member belonging to the other protocol. A refusal names the reference, the variable name or the path or the field, and never what it resolved to.

When something is wrong

A load ends in one of four states.

State What it means
Not configured No source is set. Nothing was read or written.
Applied The document was valid and changed the stored configuration.
Already current The document was valid and matched what was stored, so nothing was written.
Rejected The source could not be used. The stored configuration is untouched.

A rejection is logged at Error, naming the source and the reason. It is a whole document at a time: an unreadable path, a document that is not valid JSON, a member repeated twice inside one object, a version this plugin does not import, an unresolvable secret reference, a provider the validator refuses. Nothing is half applied, because the document is applied to a detached copy first and only reaches the live configuration once that copy has taken it whole.

Nothing resolves to a blank secret. A blank secret means keep the stored one, so a failure that produced one would leave the server running on its previous secret with nothing said about it.

Nothing here can take the plugin down. A configuration mistake is a loud log line and a server that keeps running on what it already had, never a failed plugin load that takes every SSO login with it.

Applying the identical document twice writes nothing the second time, so a restart loop against an unchanged mount does not rewrite the stored configuration on every boot.

Turning off a protection from a file leaves the same audit trace a settings-page save leaves. The declarative source is the route an operator is least likely to be watching at the moment it applies, which is exactly why it is not the quiet one.

What the settings page shows

A provider a mounted file or the environment decided cannot be edited from the settings page, and the page says so rather than letting an edit disappear at the next start.

Open one in the provider editor and every field and toggle on its form is disabled, under one sentence:

This provider is set by a configuration file or by environment variables, so it cannot be edited here. Change it at that source and restart Jellyfin. A save made here would keep the stored value and leave a record in the log.

The whole provider is frozen rather than a few fields of it, and that follows from the precedence rule above: a source declares a provider WHOLE, so a form that greyed out three fields and left the rest editable would tell you the opposite of what happens at the next start.

The connection test and the copy actions stay usable. They read and write no provider field, and they are what you need while diagnosing a provider you cannot edit here.

The provider list is not marked. Only the editor says a provider is managed, so the state is found by opening it rather than by scanning the list.

Saving the settings page while a managed provider exists is not an error. The posted values for that provider are dropped and the stored ones kept, the ignored write is logged with the protocol and the provider name and nothing that was posted, and an unrelated edit in the same save goes through. A managed provider missing from a save is put back rather than deleted.

The narrower doors refuse instead. Adding or deleting an OpenID or SAML provider a source owns is refused before anything is written, and importing a document that contains one is refused whole rather than merged with those entries dropped. Each refusal names the provider and the source that owns it, so the change can be made where it survives a restart. Those calls carry a single-provider intent, so doing nothing quietly would be the misleading answer; a settings-page save is a whole configuration in which a managed provider is usually just an untouched form riding along, which is why that one is ignored rather than refused.

With no declarative source configured, the managed set is empty and nothing on the page changes. If the page cannot read the set at all it stays open rather than freezing every form, because the refusal an administrator actually meets is on the server and does not depend on what the page learned.

When the page cannot read which providers are managed

On the release the Latest mark points at, a page that cannot read the managed set leaves every form open, as the paragraph above says. The 5.0 line answers that state differently, and the difference is visible in four places:

  • A read that fails no longer empties the set. The last set that was read stays in force, so a provider a file owns stays frozen through a transient failure instead of turning editable for as long as it lasts.
  • A frozen editor whose report could not be re-read says so. Its note carries a sentence saying this is the last answer the server gave and that it may be out of date.
  • An unfrozen editor whose report could not be read says so too, rather than looking exactly like a provider nothing owns. The blank add-new form does not, because it owns nothing yet.
  • The four refusals that block a rename, a delete or a profile save carry the same qualifier.

One residual is worth knowing before you meet it: a provider REMOVED at the file source while the report is unreadable stays frozen until a read succeeds. Reloading the dashboard clears it, and so does opening another of the plugin's tabs, which re-reads unconditionally. Those two are the routes to reach for; the server's own refusal is unaffected either way, because it never depended on what the page learned.

Clone this wiki locally