Skip to content
SHC Docs

Core concepts

Read this once. Everything else — the CLI, app definitions, flow docs — assumes this vocabulary.


The composite identity: tenant × stack × environment

Section titled “The composite identity: tenant × stack × environment”

The most important thing to understand about SHC is that a running app instance is identified by a tuple, not a name:

(tenant, stack, environment) → one deployment

This tuple is SHC’s universal primary key. File paths, vault scopes, DNS names, audit events, backup archives — all of them derive from it. See architecture/core/deployment-identity for the full rationale; here’s what you need to know day-to-day:

  • Tenant — the isolation boundary. Different tenants never see each other’s data, secrets, stacks, or events. Default: default.
  • Stack — a named instance of an app. You can install the same app multiple times with different stack names.
  • Environment — a variant of one stack (e.g. prod, staging, dev). Fully isolated data, config, secrets. Default: default.
Terminal window
shc install nextcloud # (default, nextcloud, default)
shc install nextcloud --stack cloud # (default, cloud, default)
shc install nextcloud -s cloud -e prod # (default, cloud, prod)
shc install nextcloud -s cloud -e staging # (default, cloud, staging)

Those four installs are four completely separate deployments, with separate databases, separate vault entries, separate DNS names.


A single-node SHC keeps its control-plane database as a plain SQLite file. The moment a second node joins, the database is promoted into the raft engine — an external rqlited process the daemon supervises — and replicated across the cluster. The promotion is automatic; there is no manual migration step.

  • Single-node: plain SQLite, all services run on one host
  • Multi-node: 3 or 5 raft voters over mTLS + Nebula, Raft consensus, horizontal scale-out of stacks

A node is one host running the SHC daemon.

Terminal window
shc node ls # list nodes
shc node ls -v # verbose, full IDs
shc install postgres --node <node-id> # pin a stack to a specific node

Nodes have attributes that affect scheduling: their runtime (compose / swarm / podman), their architecture (amd64/arm64), their linked storage providers, and whether they’re currently draining. See operations/cluster-setup.

SHC can drive three container runtimes (modules/node/types.go::Runtime):

RuntimeCross-node networkingTypical use
composeSingle-node only (bridge network)Single-host deployments, dev clusters, or per-node local stacks
swarmYes — Docker overlay networksMulti-node production where stacks may span hosts
podmanSingle-node onlyRootless deployments

One cluster may have a mix — some nodes running compose, others running swarm. A stack’s runtime is picked by matching the app’s requires.runtimes against what the target node supports. See app-definition — requirements.

Each node has exactly one declared runtime, set at shc init --runtime … (for the cluster-init node) or shc node join --runtime … (for workers). At install time SHC enforces that the stack’s runtime matches the target node’s runtime:

  • shc install <app> --node X --runtime swarm requires X.runtime == "swarm". Otherwise the install fails with X400955RTMPIN.
  • shc install <app> --runtime swarm (no --node) places the stack via the docker swarm scheduler against the pool of nodes where runtime == "swarm". An empty pool fails with X400956NOSWPL.
  • shc install <app> --node X --runtime compose requires X.runtime == "compose".

This is a hard constraint, not a hint: a compose-declared node cannot host swarm services even if its docker daemon happens to be swarm-attached, and vice versa. The intent is that a node’s declared runtime represents operator intent, not the docker daemon’s incidental state.

To move a stack across runtimes, shc clone and shc move accept --runtime <new>; the operation re-renders the source onto the new runtime and lands the result on a node that supports it. This is the supported runtime-conversion path — there is no in-place flip.


Three similar-sounding words, three different things. Keep them straight:

  • App — a template. Lives on disk under apps/<name>/. Never runs anything. Just definition files (app.yaml, compose.yaml, plug/socket files). Shipped with SHC or pulled from a repo.

  • Stack — the installation record of an app. Created by shc install <app> --stack <name>. One stack = one row in the control-plane database’s stacks table. The stack carries the install-time variables, which node it’s assigned to, which runtime, …

  • Deployment — the running state of a stack in a specific environment. One stack + one environment = one deployment. This is the (tenant, stack, environment) tuple above. Backups, snapshots, compose state all key off the deployment.

A stack with environments default, staging, and prod is one stack row but three deployments.


Tenants are hard isolation boundaries:

  • Separate database records scoped to the tenant
  • Separate vault scopes (so ref+vault://db_password?scope=tenant on tenant A resolves differently from the same reference on tenant B)
  • Separate networks — each tenant gets its own Docker bridge/overlay
  • Separate audit trails
  • API permissions scoped by tenant (see security.md)

The default tenant is created at shc init and is the implicit target when you don’t pass --tenant. For true multi-tenant use:

Terminal window
shc tenant create acme
shc install nextcloud --tenant acme --stack cloud

The cross-tenant --all-tenants flag on a few admin commands is guarded by an explicit admin.tenants permission — see security.md.


Apps connect to each other via sockets (providers) and plugs (consumers). Install a consumer with -i provider and SHC runs a multi-phase job pipeline to provision the connection (create a database, grant a user, issue an API key, …).

Terminal window
shc install wordpress --stack blog -i postgres
# ^^^^^^^^ provider stack

This is SHC’s killer feature — the orchestration isn’t limited to “start these containers in the right order,” it can actually provision the things apps need from each other.

Full model: plugs-and-sockets.

Related concept — vApps — let one stack host many sub-resources (many databases in one Postgres, many realms in one Keycloak). See vapps.


When SHC needs to run something — a DB migration, a backup converter, a cache flush, an integration provisioning step — it uses a job.

A job is defined in <name>.job.yaml inside an app directory. Its context selects where it runs: in an already-running service (service), in a new one-shot container (container), as an HTTP call (http), as a GraphQL mutation (graphql), as a pipeline, or as a Playwright browser script.

Three things trigger jobs:

TriggerFires whenDocs
HooksLifecycle events (pre_install, post_install, pre_backup, …)lifecycle-hooks
CronsCron schedule defined in app.yamlarchitecture/modules/schedule
Integration phasesdiscovery, integration, post_deployment on connect. unintegration and cleanup are declared but have no runner — disconnect and uninstall execute neitherplugs-and-sockets — integration phases

Run history is persisted as rows in the control-plane database — see shc job runs <stack>.


A repo is a source of apps. SHC ships with a builtin repo of ~60 apps installed with the package — the daemon catalogs them automatically. You can register additional repos pointing at:

  • A git repository (https://github.com/you/shc-apps)
  • A local bare git remote (git+file:///srv/mirrors/apps.git)
  • An OCI image (not yet)

Repos are remote-only: file-path repos were removed. To install an app from a local directory, install it directly (shc install ./path/to/app) instead of registering the directory.

Terminal window
shc repo list
shc repo add my-apps https://github.com/acme/shc-apps
shc install acme-internal-tool --repo my-apps

See app source resolution for the full search order.


Two orthogonal systems you’ll touch as an operator:

  • Storage providers — where stack volumes and backup data live. Types: local, nfs, cephfs. See operations/storage-providers.

  • Vault — where secrets live. Referenced from YAML via ref+vault://key?scope=.... Backend providers include a built-in store encrypted in the control-plane database plus 10 external options. See operations/vault.


Two streams of observability data, both sharing the same event infrastructure:

  • Events — lifecycle signals (A200100STKINS when a stack installs, V500320STKERR when it errors). Subscribe via Server-Sent Events through shc events or the HTTP API. See flow: event lifecycle.

  • Audit trail — who did what, when. Every vault read, every permission-checked API call, every tenant admin action. shc events --audit. See architecture/modules/audit.

Both streams can be pushed to an external collector (default: OpenObserve) — see operations/observability.


tenant
├── stacks (one app install → one stack)
│ ├── deployments (one stack × environment → one deployment)
│ │ ├── compose services (containers running the app)
│ │ ├── volumes (data on storage provider)
│ │ ├── vault entries (secrets at deployment scope)
│ │ ├── integrations (edges to other stacks' sockets)
│ │ └── vApp instances (sub-resources: realms, DBs, buckets, …)
│ └── stack-scoped state (hooks, crons, variables, …)
├── tenant-scoped vault entries (?scope=tenant)
├── audit trail
└── events
cluster
├── nodes (one host = one node)
├── control-plane database (SQLite; raft-replicated via rqlited in cluster mode)
├── nebula mesh (optional encrypted overlay)
├── storage providers (local / nfs / cephfs)
└── vault (providers: local + 10 external)