Skip to content
SHC Docs

App definition

An app in SHC is a directory containing one app.yaml plus a set of supporting files. Source of truth: the app-dir validator behind shc validate (modules/app/validate.go, ce module) — there is no single AppDefinition struct; each consumer reads the keys it owns (variables: modules/app/var_types.go, hooks: modules/app/hooks.go, plugs/sockets: modules/app/integration_service.go).


apps/myapp/
├── app.yaml # required — top-level metadata & configuration
├── compose.yaml # required — Jinja-templated Docker Compose
├── vars.yaml # optional — non-declared default variable values
├── backup.yaml # optional — backup converters & restore behaviour
├── *.socket.yaml # optional — sockets this app provides
├── *.plug.yaml # optional — plugs this app consumes
├── *.vsocket.yaml # optional — virtual sockets
├── *.vapp.yaml # optional — virtual app groupings
├── *.vplug.yaml # optional — virtual plugs
├── *.vvars.yaml # optional — variables injected by a vApp
└── *.job.yaml # optional — job definitions referenced by hooks / crons / integration phases

All app.yaml extra keys are allowed (model_config = ConfigDict(extra="allow")) — SHC ignores fields it doesn’t recognize rather than rejecting them. This is intentional so apps can carry custom metadata (e.g. for a UI) without SHC needing to know about every key.


FieldTypeDescription
namestr (required)App name — must match the directory name.
versionstrDefault "1.0.0". Displayed in listings; doesn’t gate anything in SHC.
descriptionstrOne-line human summary.
categorystrFree-form category (cms, database, collaboration, …) — used for filtering in the HUD.
iconstrURL or path to an icon.
homepagestrUpstream project URL.
sourcestrSource-code URL (used by app-source-resolution — see source-resolution).
tagslist[str]Free-form tags for search.

requires: declares what the app needs from the host node. Install validates these before allocating the stack.

requires:
runtimes: [compose] # or [swarm]
storage: [local, nfs] # storage-provider names this app can target
architectures: [amd64, arm64]
platforms: [linux]

Legacy top-level runtimes:, architectures:, platforms: are merged into requires: at load time (get_required_runtimes etc.) — prefer writing them under requires: in new apps.

public_hosts:
web: "{{ vars.hostname }}" # Traefik hostname → service `web`
webmail: "{{ vars.mail_hostname }}"
public_ports:
web: 80
webmail: 443

public_hosts maps service name → hostname. SHC’s routing subsystem creates Traefik routers and (optionally) DNS records for each; see networking docs. public_ports maps service name → the port SHC should expose externally (e.g. non-HTTP services).

Unlike earlier versions of these docs, public_hosts is top-level, not nested under services:.

Two sources of variable declarations:

  1. Inline under vars: in app.yaml
  2. Filevars.yaml next to app.yaml (same structure)
# Inline in app.yaml
vars:
adminUser@string!: WordPress admin username
adminPassword@secret!: WordPress admin password
mysql:
hostname@string!: MySQL hostname
port@port!: MySQL port

Each leaf uses type-annotated syntax: <name>@<type>[!]: <description>.

AnnotationMeaning
! (trailing)Required — install fails if not provided
@stringString value
@secretStored in Vault; never logged, never in events
@portInteger 1-65535
@intInteger
@bool / @booleanBoolean
@emailValidated email address
@urlURL
@pathFilesystem path
@jsonJSON-encoded structure

Variables nested under a group (mysql:) become addressable as {{ vars.mysql.hostname }} in compose.yaml, hook jobs, integration jobs — everywhere SHC renders user templates.

Full type list lives in modules/app/var_types.go (VarDecl / the declared-variable parser).

hooks:
post_install:
- setup-database
- notify-admin
pre_backup:
- flush-cache

Each value is a list of job names — not inline commands. Jobs are defined in *.job.yaml files in the same app directory. See lifecycle-hooks for the full hook model.

crons:
daily-cleanup: "0 2 * * *"
hourly-sync: "@hourly"

Maps job name → cron expression. The named job is scheduled on the node that owns the stack. Jobs are defined in *.job.yaml, same as for hooks. See architecture/modules/schedule.

There is no per-app timeouts: block in app.yaml. Operational timeouts are platform config: the timeouts.<role> config keys (defaults and role list in core/timeouts/timeouts.go, ce module) bound compose convergence, container stop, backup shell-outs, and the other pipeline phases. Per-service stop behavior uses compose’s native stop_grace_period.

Plugs and sockets are declared in standalone *.plug.yaml / *.socket.yaml files next to app.yaml — you do not hand-write them in app.yaml. See plugs-and-sockets.

connections: declares the app’s connection dependencies — typed, admin-owned credential backends the app is bound to at install time. The declaration lives in app.yaml (not a parallel file — the credential schema per type is baked into SHC), keyed by binding name:

connections:
aws:
type: aws
# Same `required:` grammar as plugs: true / false (default) /
# a Jinja condition against the merged vars. The conditional form
# expresses the either-or: mandatory UNLESS direct credentials
# were supplied via vars.
required: "{{ not vars.masterPassword }}"

A required connection that is not bound fails the install fast with X400999CONREQ (before anything is deployed), naming the declarations and the -c remedy.


Standard Docker Compose, but run through Jinja before handing to Compose. All variables, integration data, and derived names are injected as template context.

The install render is strict about undefined references: a {{ vars.x }} (or integrations.… / connections.…) lookup that misses and reaches the output with no | default(...) guard fails the install with X400998TPLUND, naming every miss — instead of silently rendering an empty string into container config. Guard idioms stay free: | default(...), branches dropped by {% if %} / is defined, and condition expressions never trip it.

services:
web:
image: wordpress:{{ vars.version | default('6.9') }}
environment:
WORDPRESS_DB_HOST: "{{ integrations.mysql.hostname }}:{{ integrations.mysql.port }}"
WORDPRESS_DB_USER: "{{ integrations.mysql.username }}"
WORDPRESS_DB_PASSWORD: "{{ integrations.mysql.password }}"
volumes:
- wp-content:/var/www/html/wp-content
labels:
traefik.enable: "true"
traefik.http.routers.web.rule: "Host(`{{ vars.hostname }}`)"
volumes:
wp-content:

Available template variables:

ExpressionSource
{{ vars.<key> }}Merged variable values
{{ integrations.<plug>.<key> }}Provider socket’s data dict for each connected plug (see plugs-and-sockets)
{{ stack }}Stack name
{{ tenant }} / {{ tenant_name }}Tenant name
{{ environment }}Environment (default "default")
{{ node_id }}Node ID assigned to the stack

Render logic: the Jinja engine in core/jinja/ (ce module), driven from the install pipeline (modules/app/install_runner.go, jinja.RenderCollect).


vars.yaml is the file form of the vars: block. Same annotated syntax — use whichever is more convenient. If both are present, inline vars: in app.yaml takes precedence on conflict.

vars.yaml
adminEmail@email!: Administrator email
apiKey@secret: API key for external service
port@port: Application port

Configures how a stack’s state is captured and restored. Optional — if absent, SHC snapshots all declared volumes and nothing else.

apps/postgres/backup.yaml
converters:
- name: pgdump
context: service
service: postgres
command: pg_dump -U {{ vars.username }} -d {{ vars.database }}
output: /backup/{{ stack }}.sql
phase: capture
volumes:
- name: data
strategy: rsync # or snapshot, exclude

Converters are jobs that run during the backup pipeline. See flow: backup/restore for the full capture/apply order and where each converter phase fires.

Volumes belonging to a bundled cache or broker are excluded, not captured — house rule, stated in full (with the one documented exception) in bundled services:

backup.yaml
exclude:
mounts:
- valkey-data

There is no single LoadAppDefinition loader — each pipeline stage reads the app-dir files it owns (all in the ce modules/app package):

  1. app.yaml is read via safe-YAML by the consumer that needs it — the validator (validate.go), variable typing (var_types.go, vars_validate.go), ingress injection (inject_traefik.go).
  2. vars.yaml (if present) merges into the declared-variable set (var_types.go).
  3. *.socket.yaml / *.plug.yaml are globbed by the integration machinery (integration_service.go, integration_render.go); *.vapp.yaml / *.vsocket.yaml / *.vplug.yaml / *.vvars.yaml by the vapp runner (vapp_runner.go).
  4. Declared-variable parsing turns every annotated leaf into a VarDecl record available for schema validation (var_types.go).
  5. A missing or invalid app.yaml fails at the consumer that first needs it — install rejects, shc validate reports.

Some rules aren’t expressible as field types. Examples:

  • Service names in public_hosts / public_ports must exist in compose.yaml. Checked late during install (during compose rendering).
  • Required plugs must be connectable — enforced by install’s plug-matching step.
  • Required variables must be provided — install rejects with X400903INVAPP if a ! var is missing.
  • Job names in hooks / crons / integration phases must resolve to a *.job.yaml — logged as a warning at runtime (X500908HKFAIL) rather than rejected at install time, so a typo in post_uninstall doesn’t block an install.

Each file in an app directory goes through a different transform pipeline before SHC hands it to Docker:

File patternProcessing
app.yamlmetadata only — no templating
vars.yamlvault references resolved
compose.yamlJinja2 → vault → enrichment
*.socket.yaml / *.plug.yaml3-part split (discovery, data, jobs)
*.vapp.yaml / *.vplug.yaml / *.vsocket.yamlJinja2 → vault
*.vvars.yamlvault references resolved
*.export.yamlJinja2 → vault
backup.yamlconverter definitions (isolated, no Jinja2)

For files that go through Jinja2, the order is strict:

1. YAML load → parse, preserve !ref+vault:// as SecretRef objects
2. Jinja2 templating → expand {{ vars }}, {{ tenant }}, {{ integrations }}, …
3. Vault resolution → resolve !ref+vault:// and !ref+env://

Jinja2 runs before vault resolution, so vault paths can be dynamic:

vars.yaml
secret_name: admin_password
# compose.yaml — Jinja2 first
password: !ref+vault://{{ vars.secret_name }}
# After Jinja2: !ref+vault://admin_password
# After vault: the-actual-secret

Available variables in any Jinja2-processed file:

VariableDescription
tenanttenant name
stackstack name
environmentenvironment name
deploymentfull deployment id ({tenant}_{environment}_{stack})
nodetarget node id
varsmerged + resolved variables from vars.yaml
integrationsintegration socket values (compose context)
integrationsingle integration context (plug/socket context)
pathsSHC path locations

paths.* resolves to:

PathDescription
paths.base_dirproject root (dev mode only)
paths.state_homestate directory (database)
paths.cache_homecache directory (git repos)
paths.runtime_dirruntime directory (PID, socket)
paths.logs_dirlogs directory
paths.sqliteSQLite DB path (single-node)

Secrets use !ref+vault:// (not Jinja2 {{ secrets.* }}):

# Simple
password: !ref+vault://db_password
# Scoped lookup
password: !ref+vault://db_password?scope=tenant
# Auto-generate if missing
password: !ref+vault://db_password?autogen
password: !ref+vault://db_password?autogen&length=64&charset=alnum
# Environment variable (with default)
api_key: !ref+env://API_KEY?default=test

Parameters:

ParameterDescription
scopetenant, stack, environment, deployment
autogenauto-generate and store if missing
lengthgeneration length (default 32)
charsetnamed set or character range (see below); default hex
defaultdefault value if env var not found (!ref+env:// only)

Named character sets:

CharsetCharacters
alnumA-Za-z0-9
alphaA-Za-z
num0-9
hex0-9a-f (default)
base64A-Za-z0-9+/

Custom regex-style character ranges are also accepted (a leading or trailing - is a literal):

password: !ref+vault://key?autogen&charset=a-z0-9
token: !ref+vault://key?autogen&charset=0-9a-f
key: !ref+vault://key?autogen&charset=A-Za-z0-9_-

When the charset spans more than one character class (lowercase, uppercase, digits, specials), the generated value always contains at least one character from each covered class, so class-counting password policies are satisfied on every mint.


apps/hello/app.yaml
name: hello
version: "1.0.0"
description: Minimal hello-world example
category: example
vars:
message@string!: Message to display
port@port: Port to listen on
apps/hello/compose.yaml
services:
web:
image: nginx:alpine
environment:
MESSAGE: "{{ vars.message }}"
ports:
- "{{ vars.port | default(8080) }}:80"
Terminal window
shc install hello \
--stack hello-world \
--var message="Hello, SHC!" \
--var port=9000