Lifecycle hooks
Hooks let an app run jobs at well-defined points in the SHC
orchestration pipeline — e.g. initialize a database after
pre_install, dump it before a backup, notify an external system
after install completes.
Source of truth:
modules/app/hooks.go
(the HookExecutor),
modules/job/types.go
(the JobDefinition schema),
modules/job/service.go
(JobLoader, JobExecutor).
For where exactly each hook fires in the pipeline, see the flow docs: install · upgrade · uninstall · backup/restore.
The 10 hooks
Section titled “The 10 hooks”| Hook | Fires at |
|---|---|
pre_install | After variables are merged, before compose up (install step 16.9) |
post_install | After compose up succeeds, before A200100STKINS event |
pre_update | Before re-rendering compose during update |
post_update | After compose up on the new config succeeds |
pre_uninstall | Before compose down (uninstall step 6) |
post_uninstall | After compose down and network removal |
pre_backup | Before volumes are rsync’d into staging |
post_backup | After restic snapshot completes |
pre_restore | Before extracting the backup into target stack volumes |
post_restore | After volumes are restored and converters applied |
All hook names are checked against hookNames in
hooks.go; unknown names are
ignored.
How a hook is defined
Section titled “How a hook is defined”Hooks are declared in app.yaml as a map from hook name to a list of
job names — not inline commands. Job names refer to job
definitions loaded from *.job.yaml files in the same app directory.
hooks: post_install: - init-db - notify-admin pre_backup: - flush-cache pre_restore: - drop-existing-schemaname: init-dbcontext: serviceservice: postgrescommand: | psql -U {{ vars.admin_user }} -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"timeout: 60on_failure: abortJobs run sequentially in the order they’re listed. One job fails → the hook is considered failed, but see “failure behaviour” below.
Job contexts
Section titled “Job contexts”A job’s context field selects where it runs. The available
contexts (from JobContext in
job/types.go):
| Context | Runs in | Typical use |
|---|---|---|
service | An exec on an already-running compose service | Seed a database after post_install |
container | A new one-shot container (image field required) | Run migrations in a throwaway container (pre_install before the stack is up) |
http | A host-side HTTP request to url | Ping a webhook, hit an admin API |
graphql | A host-side GraphQL mutation against url | Call a GraphQL admin endpoint |
pipeline | A sequence of steps (each a sub-job) | Multi-step provisioning |
playwright | A Playwright browser script against url | Automate a browser-based setup UI |
Allowed contexts per hook
Section titled “Allowed contexts per hook”Not every context is valid for every hook — pre_install can’t
exec into services that don’t exist yet. The mapping is defined
in allowedContexts in
hooks.go:
| Hook | Allowed contexts |
|---|---|
pre_install | http, container |
post_install | http, container, service |
pre_update | http, container, service |
post_update | http, container, service |
pre_uninstall | http, container, service |
post_uninstall | http, container |
pre_backup | http, container |
post_backup | http, container, service |
pre_restore | http, container |
post_restore | http, container, service |
Jobs using a disallowed context are skipped with a warning
(X500908HKFAIL) — the hook is not aborted, the invalid job simply
doesn’t run. The helper _validate_job_context enforces this.
Why service is disallowed in pre_install and pre_restore:
the stack hasn’t started yet, so there’s no container to exec into.
Why service is disallowed in post_uninstall and pre_backup:
at post_uninstall the containers are gone; at pre_backup SHC
expects to be able to stop-snap-restart volumes, and a service
exec would contend with that.
Variables available to hook jobs
Section titled “Variables available to hook jobs”The HookExecutor assembles a variable dict before handing the job
to JobExecutor:
vars.yaml— the merged variable dict persisted during install (see install step 16.2). Exposed to templates asvars.*.- Secret references resolved — any
ref+vault://...strings invars.yamlare resolved before the job runs, so commands see real values, not URIs. This is specifically so bootstrap hooks (e.g. hashing an admin password) see the real password. SeeloadDeploymentVarsinhooks.go. Secret resolution usesstrict=False— an unresolvable ref leaves the URI literal in place rather than aborting. - Job-level constants —
stack_name,tenant_name,environment,node_id,deployment_dirpassed through theJobExecutorconstructor.
In job YAML, reference them with Jinja:
command: | echo "installing {{ stack_name }} in {{ environment }}" psql -U {{ vars.admin_user }} -c "SELECT 1;"on_failure and the hook failure model
Section titled “on_failure and the hook failure model”Each job has an on_failure field (see OnFailure enum). But note
the hook-level contract is independent:
At the job level
Section titled “At the job level”on_failure: abort— if the job fails, abort the hook (subsequent jobs in the same hook list don’t run).on_failure: continue— log the failure, run the next job anyway.
At the hook level (the _run_hook_safe contract)
Section titled “At the hook level (the _run_hook_safe contract)”The run_hook method in HookExecutor wraps its whole body in a
try/except that catches BaseError, OSError, RuntimeError. A hook
failure — either an on_failure: abort job exiting non-zero, or
any other exception — is logged as a warning (X500908HKFAIL)
and does not propagate out of run_hook to the caller.
That means: the pipeline treats hooks as best-effort by default.
A failed post_install hook does not fail the install. A failed
pre_backup hook does not fail the backup.
If you need a hook that does abort the parent operation, you have
to do it from the pipeline side — which currently means patching
SHC, not your app. The only hook that is pipeline-fatal today is
pre_install: the install flow’s
step 16.9
runs _run_hook_safe, which itself swallows the error — so the
install proceeds. If pre_install is expected to provision something
the container depends on, write the container to retry or refuse to
start until the precondition is met, don’t rely on the hook failing
the install.
Job file loading
Section titled “Job file loading”JobLoader scans the app directory
(paths.app_dir(tenant, stack, environment)) for files matching
*.job.yaml. Each file is parsed as a single JobDefinition. The
lookup by job name is linear but cached per hook invocation.
Job files are copied into the deployment during install step 5 alongside the rest of the app sources.
Common patterns
Section titled “Common patterns”Bootstrap a database after install
Section titled “Bootstrap a database after install”hooks: post_install: - create-schemaname: create-schemacontext: serviceservice: postgrescommand: | psql -U {{ vars.admin_user }} -d {{ vars.db_name }} -c " CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY); "timeout: 60on_failure: abortRun a migration in a throwaway container before the stack starts
Section titled “Run a migration in a throwaway container before the stack starts”hooks: pre_install: - migratename: migratecontext: containerimage: myapp:{{ vars.version }}entrypoint: ["python", "migrate.py"]networks: - shc_{{ tenant_name }}volumes: - "{{ deployment_dir }}/data:/data"timeout: 300on_failure: abortNotify an external system via webhook
Section titled “Notify an external system via webhook”name: notifycontext: httpurl: https://hooks.example.com/deploymethod: POSTheaders: Authorization: "Bearer {{ vars.webhook_token }}"body: stack: "{{ stack_name }}" tenant: "{{ tenant_name }}" event: "installed"max_retries: 3retry_interval: 10Flush a cache before backup so the snapshot is consistent
Section titled “Flush a cache before backup so the snapshot is consistent”hooks: pre_backup: - flush-cachename: flush-cachecontext: containerimage: redis:7entrypoint: ["redis-cli", "-h", "redis", "BGSAVE"]networks: - shc_{{ tenant_name }}timeout: 30on_failure: continueon_failure: continue because a cache that can’t flush is not worse
than not flushing it — the backup should still proceed.
Debugging
Section titled “Debugging”Where to look when a hook didn’t run
Section titled “Where to look when a hook didn’t run”- Check the daemon log for
X500908HKFAIL— that’s the hook failure event class. shc stack logs <stack>— the container-context job’s output.- The
JobRunModeltable has one row per executed job:shc job runs <stack>shows recent runs. - For
service-context jobs,docker logson the service container will show theexec’d command’s output.
Hook silently skipped
Section titled “Hook silently skipped”Most common cause: a job is using a context the hook doesn’t allow
(e.g. pre_install with context: service). Check for
X500908HKFAIL with context 'X' is not allowed for hook 'Y' in the
message.
Variables not resolved
Section titled “Variables not resolved”If {{ vars.admin_password }} appears literally in a job’s command,
the variable isn’t in vars.yaml (typo or missing from the merged
vars). If a ref+vault://... URI appears literally, the vault
provider couldn’t resolve it (see vault provider logs).
Related
Section titled “Related”- App definition — the full
app.yamlschema - Plugs & sockets — integrations (plugs/sockets) also use jobs (unintegration jobs)
- Flow: install — where
pre_install/post_installfire - Flow: upgrade —
pre_update/post_update - Flow: uninstall —
pre_uninstall/post_uninstall - Flow: backup/restore —
pre_backup/post_backup/pre_restore/post_restore - Architecture: job module — the
JobExecutorinternals - Error reference —
X500908HKFAIL— the hook-failure event