Skip to content
SHC Docs

Config module

Source: modules/config/.

Verified against HEAD (DI composition root, scope collapse, ent ORM) — 2026-06-22.

CLIshc config
Subcommands5 (get, set, unset, ls (alias list), edit)
HTTP endpointssee the route reference
Cross-module depsauth (write handlers gated on platform:manage)
modules/config/
├── register.go # module wiring — builds the cobra subtree + chi router
├── service.go # Service — scoped DB-overlay reads/writes (ent ORM)
├── daemon_client.go # CLI → daemon HTTP client for shc.config.* methods
├── db_provider.go # global-tier DBProvider for the core/config koanf tree
├── router.go # /api/method/shc.config.* handlers
├── errors.go
├── events.go
├── models.go # ConfigModel row mapping (table "config")
├── types.go # ConfigEntry, ListFilter, request/response shapes
├── editor.go # `shc config edit` round-trip
├── local_store.go
├── state.go
└── commands/ # get / set / unset / list (ls) / edit cobra leaves

The config module is the operator-facing CRUD for SHC’s configuration cascade. It does not define the cascade itself (the scope ladder lives in core/scope, and the file/global koanf tree in core/config + default.yaml), nor does it know anything about individual keys; it just reads and writes values scoped to one of the five tiers global, tenant, environment, stack, or deployment.

The HTTP API (/api/method/shc.config.{get,set,unset,list}) and CLI (shc config {get,set,unset,ls,edit}) live here. The module’s Service (constructed via NewService / NewServiceWithOptions and injected at the DI composition root) writes scoped overlays to the database through the ent ORM; core/config at runtime layers the global DB tier on top of default.yaml values, while per-tenant/stack/environment/ deployment overrides resolve through Service.Get.

Configuration loads in two layers:

Loaded once at startup, lowest to highest priority:

  1. default.yaml — bundled vendor defaults
  2. /etc/shc/config.yaml — system-wide config
  3. /etc/shc/config.d/*.yaml — system drop-ins (alphabetical)
  4. ~/.config/shc/config.yaml — user config (non-root only)
  5. dev.yaml — developer overrides (dev mode only)

File-config changes require a server restart.

Runtime, hot-reloadable, cascades by scope. Lowest to highest specificity (the single ladder defined in core/scope):

  1. Global (base configuration)
  2. Tenant (tenant-specific overrides)
  3. Environment (tenant + environment)
  4. Stack (tenant + stack)
  5. Deployment (tenant + stack + environment — most specific)

Stack outranks Environment: a value set at (tenant, stack) wins over one set at (tenant, environment). Service.Get walks this cascade most-specific-tier first and returns the first hit (precedence ranks 0=Global … 4=Deployment per scope.Rank).

The global DB tier always overrides file config for overlapping keys in the core/config tree; the narrower tiers are resolved at query time through the scoped Service.Get read path.

A row’s scope is not encoded into the key — it is determined by which of the nullable tenant_name / stack_name / environment columns on the config table are populated (ConfigModel.Scope() delegates to scope.Classify). The five tiers, broadest to narrowest:

ScopePopulated columnsscope.Rank
global(all NULL)0
tenanttenant_name1
environmenttenant_name, environment2
stacktenant_name, stack_name3
deploymenttenant_name, stack_name, environment4

The unique constraint is (path, tenant_name, stack_name, environment), so one path may carry one row per tier.

If --scope is omitted on set/unset, the CLI derives the default from the active context (defaultScope): stack when both tenant and stack are set, tenant when only a tenant is set, otherwise global. The --scope flag accepts global (alias base), tenant, environment (alias env), stack, and deployment.

Terminal window
# Read (always merges all scopes)
shc config get backup.retention_days
shc config get backup.retention_days --scope tenant # specific scope, no inheritance
shc config get # entire effective config
shc config get --scope tenant
# Write (scope defaults from active context: stack > tenant > global)
shc config set backup.retention_days 14
shc config set backup.retention_days 14 --scope tenant
shc config set backup.retention_days 14 --scope deployment
shc config unset backup.retention_days
# Vault and env references
shc config set database.password 'ref+vault://db_password'
shc config set smtp.api_key 'ref+env://SMTP_API_KEY?default=test-key'

Values are parsed as YAML types unless quoted:

InputStored as
true / falseboolean
42, 3.14int / float
[a, b], {k: v}list / dict
"true"string "true"
"0 2 * * *"string (quote required for cron)
'["data"]'list (JSON form)

Two disjoint surfaces (B11 “no hybrids” — a key resolves from exactly one of them, never both):

The module’s Service (injected at the DI composition root) owns the scoped DB-overlay walk. Lookups take a scope.Ctx (the active tenant/stack/environment) and the shared scope.Scope tier type.

import (
configmod "gitlab.com/bitspur/selfhosted-cloud/ce/modules/config"
"gitlab.com/bitspur/selfhosted-cloud/ce/core/scope"
)
svc := configmod.NewService(db) // or NewServiceWithOptions(ServiceOptions{...})
sc := scope.Ctx{Tenant: "acme", Stack: "web", Environment: "prod"}
// Effective value — walks Deployment > Stack > Environment > Tenant > Global,
// returns the first hit (nil, nil on a miss).
value, err := svc.Get(ctx, "backup.retention_days", sc)
// Exact scope, no precedence walk.
value, err = svc.GetAtScope(ctx, "backup.retention_days", scope.Tenant, sc)
// Write / remove at a specific tier (rejects startup-only prefixes).
err = svc.Set(ctx, "backup.retention_days", 14, scope.Stack, sc)
ok, err := svc.Delete(ctx, "backup.retention_days", scope.Stack, sc)

There is no package-level config.Get / GetScoped / Scope, and no types.ScopeTenant / types.ScopeLocal — the scope ladder collapsed into the single core/scope.Scope type (Global/Tenant/Environment/Stack/Deployment); there is no Local tier.

The process-wide koanf tree carries the global DB tier plus the file layers. It is a *Config value (not package-level functions) with typed accessors and a secret-resolving read:

import "gitlab.com/bitspur/selfhosted-cloud/ce/core/config"
cfg, _ := config.Load(ctx, config.LoadOpts{ /* … */ })
host := cfg.String("server.host")
n := cfg.Int("cluster.peer_timeout")
// Resolve secret references against the registered providers.
value, err := cfg.GetResolved(ctx, "database.password")

Config values may reference external sources rather than store the literal:

ProviderSyntaxDescription
vaultref+vault://secret_keyVault secret lookup (scope-resolved)
envref+env://VAR?default=valueEnvironment variable on the server process

Note: the env provider reads from the server process environment, not the CLI user’s shell. For CLI-triggered operations that need secrets, use the vault provider.

A key can be system-only, scoped-only, or both:

SettingScope levelDefault
server.*system only
cluster.peer_timeoutsystem only30
cluster.heartbeat_intervalsystem only5
cluster.ports.*system only
database.*system only
logging.levelsystem + scopedINFO
logging.formatsystem + scopedjson
collector.*startup-only
backup.retention_daysscoped only7
backup.schedulescoped only
quota.disk_enabledsystem onlyfalse
quota.* (limits)scoped only
dev.*startup-only
keycloak.*system only

System-only keys are loaded once at startup; setting them at a narrower scope has no effect. Startup-only prefixes go further: Service.Set rejects writes under dev, vault, collector, and rate_limit with X400050CFGSTP — those layers must come from the yaml files. The config keys reference lists every key.

Vault paths serialise the same scope ladder (modules/vault/paths.go, BuildPath). Every key is rooted under the shc prefix; the broadest tier (scope.Global) maps to the system segment, and the narrower tiers nest under tenants/{tenant}/…:

shc/
├── system/{key} # scope.Global
└── tenants/{tenant}/
├── {key} # scope.Tenant
├── environments/{env}/{key} # scope.Environment
├── stacks/{stack}/{key} # scope.Stack
└── stacks/{stack}/environments/{env}/{key} # scope.Deployment
Path patternScopeOperator-writable
shc/system/*globalno (server / startup-only)
shc/tenants/{tenant}/*tenant + narroweryes (shc vault set)