Skip to content
SHC Docs

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.


HookFires at
pre_installAfter variables are merged, before compose up (install step 16.9)
post_installAfter compose up succeeds, before A200100STKINS event
pre_updateBefore re-rendering compose during update
post_updateAfter compose up on the new config succeeds
pre_uninstallBefore compose down (uninstall step 6)
post_uninstallAfter compose down and network removal
pre_backupBefore volumes are rsync’d into staging
post_backupAfter restic snapshot completes
pre_restoreBefore extracting the backup into target stack volumes
post_restoreAfter volumes are restored and converters applied

All hook names are checked against hookNames in hooks.go; unknown names are ignored.


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.

apps/myapp/app.yaml
hooks:
post_install:
- init-db
- notify-admin
pre_backup:
- flush-cache
pre_restore:
- drop-existing-schema
apps/myapp/init-db.job.yaml
name: init-db
context: service
service: postgres
command: |
psql -U {{ vars.admin_user }} -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
timeout: 60
on_failure: abort

Jobs run sequentially in the order they’re listed. One job fails → the hook is considered failed, but see “failure behaviour” below.


A job’s context field selects where it runs. The available contexts (from JobContext in job/types.go):

ContextRuns inTypical use
serviceAn exec on an already-running compose serviceSeed a database after post_install
containerA new one-shot container (image field required)Run migrations in a throwaway container (pre_install before the stack is up)
httpA host-side HTTP request to urlPing a webhook, hit an admin API
graphqlA host-side GraphQL mutation against urlCall a GraphQL admin endpoint
pipelineA sequence of steps (each a sub-job)Multi-step provisioning
playwrightA Playwright browser script against urlAutomate a browser-based setup UI

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:

HookAllowed contexts
pre_installhttp, container
post_installhttp, container, service
pre_updatehttp, container, service
post_updatehttp, container, service
pre_uninstallhttp, container, service
post_uninstallhttp, container
pre_backuphttp, container
post_backuphttp, container, service
pre_restorehttp, container
post_restorehttp, 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.


The HookExecutor assembles a variable dict before handing the job to JobExecutor:

  1. vars.yaml — the merged variable dict persisted during install (see install step 16.2). Exposed to templates as vars.*.
  2. Secret references resolved — any ref+vault://... strings in vars.yaml are 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. See loadDeploymentVars in hooks.go. Secret resolution uses strict=False — an unresolvable ref leaves the URI literal in place rather than aborting.
  3. Job-level constantsstack_name, tenant_name, environment, node_id, deployment_dir passed through the JobExecutor constructor.

In job YAML, reference them with Jinja:

command: |
echo "installing {{ stack_name }} in {{ environment }}"
psql -U {{ vars.admin_user }} -c "SELECT 1;"

Each job has an on_failure field (see OnFailure enum). But note the hook-level contract is independent:

  • 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.


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.


app.yaml
hooks:
post_install:
- create-schema
create-schema.job.yaml
name: create-schema
context: service
service: postgres
command: |
psql -U {{ vars.admin_user }} -d {{ vars.db_name }} -c "
CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY);
"
timeout: 60
on_failure: abort

Run a migration in a throwaway container before the stack starts

Section titled “Run a migration in a throwaway container before the stack starts”
app.yaml
hooks:
pre_install:
- migrate
migrate.job.yaml
name: migrate
context: container
image: myapp:{{ vars.version }}
entrypoint: ["python", "migrate.py"]
networks:
- shc_{{ tenant_name }}
volumes:
- "{{ deployment_dir }}/data:/data"
timeout: 300
on_failure: abort
notify.job.yaml
name: notify
context: http
url: https://hooks.example.com/deploy
method: POST
headers:
Authorization: "Bearer {{ vars.webhook_token }}"
body:
stack: "{{ stack_name }}"
tenant: "{{ tenant_name }}"
event: "installed"
max_retries: 3
retry_interval: 10

Flush a cache before backup so the snapshot is consistent

Section titled “Flush a cache before backup so the snapshot is consistent”
app.yaml
hooks:
pre_backup:
- flush-cache
flush-cache.job.yaml
name: flush-cache
context: container
image: redis:7
entrypoint: ["redis-cli", "-h", "redis", "BGSAVE"]
networks:
- shc_{{ tenant_name }}
timeout: 30
on_failure: continue

on_failure: continue because a cache that can’t flush is not worse than not flushing it — the backup should still proceed.


  1. Check the daemon log for X500908HKFAIL — that’s the hook failure event class.
  2. shc stack logs <stack> — the container-context job’s output.
  3. The JobRunModel table has one row per executed job: shc job runs <stack> shows recent runs.
  4. For service-context jobs, docker logs on the service container will show the exec’d command’s output.

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.

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).