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).
Directory layout
Section titled “Directory layout”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 phasesAll 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.
app.yaml top-level fields
Section titled “app.yaml top-level fields”Identity & discovery
Section titled “Identity & discovery”| Field | Type | Description |
|---|---|---|
name | str (required) | App name — must match the directory name. |
version | str | Default "1.0.0". Displayed in listings; doesn’t gate anything in SHC. |
description | str | One-line human summary. |
category | str | Free-form category (cms, database, collaboration, …) — used for filtering in the HUD. |
icon | str | URL or path to an icon. |
homepage | str | Upstream project URL. |
source | str | Source-code URL (used by app-source-resolution — see source-resolution). |
tags | list[str] | Free-form tags for search. |
Requirements
Section titled “Requirements”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 entry points
Section titled “Public entry points”public_hosts: web: "{{ vars.hostname }}" # Traefik hostname → service `web` webmail: "{{ vars.mail_hostname }}"
public_ports: web: 80 webmail: 443public_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:.
Variables
Section titled “Variables”Two sources of variable declarations:
- Inline under
vars:inapp.yaml - File —
vars.yamlnext toapp.yaml(same structure)
# Inline in app.yamlvars: adminUser@string!: WordPress admin username adminPassword@secret!: WordPress admin password mysql: hostname@string!: MySQL hostname port@port!: MySQL portEach leaf uses type-annotated syntax:
<name>@<type>[!]: <description>.
| Annotation | Meaning |
|---|---|
! (trailing) | Required — install fails if not provided |
@string | String value |
@secret | Stored in Vault; never logged, never in events |
@port | Integer 1-65535 |
@int | Integer |
@bool / @boolean | Boolean |
@email | Validated email address |
@url | URL |
@path | Filesystem path |
@json | JSON-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-cacheEach 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.
Timeouts
Section titled “Timeouts”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
Section titled “Plugs and sockets”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
Section titled “Connections”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.
compose.yaml
Section titled “compose.yaml”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:
| Expression | Source |
|---|---|
{{ 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
Section titled “vars.yaml”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.
adminEmail@email!: Administrator emailapiKey@secret: API key for external serviceport@port: Application portbackup.yaml
Section titled “backup.yaml”Configures how a stack’s state is captured and restored. Optional — if absent, SHC snapshots all declared volumes and nothing else.
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, excludeConverters 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:
exclude: mounts: - valkey-dataHow an app definition is loaded
Section titled “How an app definition is loaded”There is no single LoadAppDefinition loader — each pipeline stage
reads the app-dir files it owns (all in the ce modules/app package):
app.yamlis 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).vars.yaml(if present) merges into the declared-variable set (var_types.go).*.socket.yaml/*.plug.yamlare globbed by the integration machinery (integration_service.go,integration_render.go);*.vapp.yaml/*.vsocket.yaml/*.vplug.yaml/*.vvars.yamlby the vapp runner (vapp_runner.go).- Declared-variable parsing turns every annotated leaf into a
VarDeclrecord available for schema validation (var_types.go). - A missing or invalid
app.yamlfails at the consumer that first needs it — install rejects,shc validatereports.
Validation rules beyond struct tags
Section titled “Validation rules beyond struct tags”Some rules aren’t expressible as field types. Examples:
- Service names in
public_hosts/public_portsmust exist incompose.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
X400903INVAPPif 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 inpost_uninstalldoesn’t block an install.
How files are processed
Section titled “How files are processed”Each file in an app directory goes through a different transform pipeline before SHC hands it to Docker:
| File pattern | Processing |
|---|---|
app.yaml | metadata only — no templating |
vars.yaml | vault references resolved |
compose.yaml | Jinja2 → vault → enrichment |
*.socket.yaml / *.plug.yaml | 3-part split (discovery, data, jobs) |
*.vapp.yaml / *.vplug.yaml / *.vsocket.yaml | Jinja2 → vault |
*.vvars.yaml | vault references resolved |
*.export.yaml | Jinja2 → vault |
backup.yaml | converter definitions (isolated, no Jinja2) |
Templating order
Section titled “Templating order”For files that go through Jinja2, the order is strict:
1. YAML load → parse, preserve !ref+vault:// as SecretRef objects2. 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:
secret_name: admin_password
# compose.yaml — Jinja2 firstpassword: !ref+vault://{{ vars.secret_name }}# After Jinja2: !ref+vault://admin_password# After vault: the-actual-secretTemplate context
Section titled “Template context”Available variables in any Jinja2-processed file:
| Variable | Description |
|---|---|
tenant | tenant name |
stack | stack name |
environment | environment name |
deployment | full deployment id ({tenant}_{environment}_{stack}) |
node | target node id |
vars | merged + resolved variables from vars.yaml |
integrations | integration socket values (compose context) |
integration | single integration context (plug/socket context) |
paths | SHC path locations |
paths.* resolves to:
| Path | Description |
|---|---|
paths.base_dir | project root (dev mode only) |
paths.state_home | state directory (database) |
paths.cache_home | cache directory (git repos) |
paths.runtime_dir | runtime directory (PID, socket) |
paths.logs_dir | logs directory |
paths.sqlite | SQLite DB path (single-node) |
Vault references
Section titled “Vault references”Secrets use !ref+vault:// (not Jinja2 {{ secrets.* }}):
# Simplepassword: !ref+vault://db_password
# Scoped lookuppassword: !ref+vault://db_password?scope=tenant
# Auto-generate if missingpassword: !ref+vault://db_password?autogenpassword: !ref+vault://db_password?autogen&length=64&charset=alnum
# Environment variable (with default)api_key: !ref+env://API_KEY?default=testParameters:
| Parameter | Description |
|---|---|
scope | tenant, stack, environment, deployment |
autogen | auto-generate and store if missing |
length | generation length (default 32) |
charset | named set or character range (see below); default hex |
default | default value if env var not found (!ref+env:// only) |
Named character sets:
| Charset | Characters |
|---|---|
alnum | A-Za-z0-9 |
alpha | A-Za-z |
num | 0-9 |
hex | 0-9a-f (default) |
base64 | A-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-9token: !ref+vault://key?autogen&charset=0-9a-fkey: !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.
Minimal complete example
Section titled “Minimal complete example”name: helloversion: "1.0.0"description: Minimal hello-world examplecategory: example
vars: message@string!: Message to display port@port: Port to listen onservices: web: image: nginx:alpine environment: MESSAGE: "{{ vars.message }}" ports: - "{{ vars.port | default(8080) }}:80"shc install hello \ --stack hello-world \ --var message="Hello, SHC!" \ --var port=9000Related
Section titled “Related”- Creating apps — tutorial walkthrough
- Lifecycle hooks — hook/job contract
- Plugs & sockets — integration system
- vApps — virtual apps and vSockets
- Source resolution — where SHC finds apps on disk
- Architecture: app module — the consumer side
- Flow: install — where
app.yamlis read and validated