Creating apps — tutorial
This walkthrough builds a complete SHC app end-to-end. By the end you’ll understand how the file layout, variable model, integration system, hooks, and backup converters fit together — with working code at each step.
For the reference-style schema of each file, see app-definition. For the integration system specifically, see plugs-and-sockets.
What we’re building. A tiny “feedback form” app called
feedback — a single web service that stores form submissions in
a Postgres database. It:
- reads its admin password from the vault (autogenerated)
- connects to an existing Postgres stack via a plug
- seeds its own DB schema in a
post_installhook - dumps the DB in a
pre_backupconverter so backups are consistent
Prerequisites: a working SHC install (see installation) and a Postgres stack to connect to:
shc install postgres --stack dbStep 1 — Create the app directory
Section titled “Step 1 — Create the app directory”Apps can be installed straight from a local directory — repos are remote-only (git / http(s)), so local development uses direct-path installs. Create a working directory for the app:
mkdir -p ~/my-apps/feedbackLater, shc install ~/my-apps/feedback installs it directly. When the
app is ready to share, push ~/my-apps to a git remote and register
that with shc repo add my-apps https://… so every node can install
it by name.
Step 2 — app.yaml: the metadata
Section titled “Step 2 — app.yaml: the metadata”Create ~/my-apps/feedback/app.yaml:
name: feedbackversion: "1.0.0"description: Simple feedback-form app with Postgres backendcategory: productivitytags: - feedback - forms
requires: runtimes: [compose, swarm]
public_hosts: web: "{{ vars.hostname }}"
vars: hostname@string!: Public hostname (e.g. feedback.example.com) adminEmail@email!: Administrator email address adminPassword: "!ref+vault://adminPassword?autogen&length=32"A few things worth pointing out:
public_hostsis a top-level map ofservice_name → hostname, not nested underservices:. The service name (web) must match a service incompose.yaml.- Required variables use the trailing
!:hostname@string!means install fails without it. - Autogenerated secrets don’t need user input. The first install generates a 32-char password and stores it in the vault; subsequent renders reuse it. See vault — reference syntax.
Step 3 — Declare a plug
Section titled “Step 3 — Declare a plug”We want feedback to get its own database inside a shared Postgres
stack. That’s exactly what the database vApp on apps/postgres/
is for — see vapps.
Create ~/my-apps/feedback/postgres.plug.yaml:
data: database: "{{ stack }}_{{ environment }}" username: "{{ stack }}_{{ environment }}" password: "!ref+vault://dbPassword?autogen&length=32"socket: is derived from the filename (postgres), so we don’t
need to declare it. The data block gets validated against the
provider’s schema at connect time — see the Postgres socket schema.
The template uses {{ stack }} and {{ environment }} so every
install gets its own database name and user, even if multiple
tenants install feedback against the same Postgres.
Step 4 — compose.yaml: the actual services
Section titled “Step 4 — compose.yaml: the actual services”Create ~/my-apps/feedback/compose.yaml:
services: web: image: python:3.12-slim command: > python -m http.server 8080 --directory /app/public volumes: - static:/app/public - ./files/app.py:/app/app.py:ro environment: ADMIN_EMAIL: "{{ vars.adminEmail }}" ADMIN_PASSWORD: "{{ vars.adminPassword }}" DATABASE_URL: "postgres://{{ integrations.postgres.username }}:{{ integrations.postgres.password }}@{{ integrations.postgres.hostname }}:{{ integrations.postgres.port }}/{{ integrations.postgres.database }}" labels: traefik.enable: "true" traefik.http.routers.feedback.rule: "Host(`{{ vars.hostname }}`)" traefik.http.routers.feedback.tls: "true" networks: - default - traefik
volumes: static:
networks: traefik: external: trueKey template variables:
{{ vars.* }}— everything declared in thevars:block (and auto-generated secrets resolve to their real values, not theref+vault://URI){{ integrations.postgres.* }}— the socket’s data dict, keyed by the plug name (our plug file was namedpostgres.plug.yaml→ plug namepostgres){{ stack }},{{ environment }},{{ tenant_name }},{{ node_id }}— automatically injected
See app-definition — compose.yaml for the full template context.
Step 5 — Add a post-install hook
Section titled “Step 5 — Add a post-install hook”We want to create the schema after install succeeds. Jobs live in
*.job.yaml files next to app.yaml.
Create ~/my-apps/feedback/init-schema.job.yaml:
name: init-schemacontext: containerimage: postgres:16-alpineentrypoint: - psql - "{{ integrations.postgres.database_url }}" - -c - | CREATE TABLE IF NOT EXISTS submissions ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, message TEXT NOT NULL, submitted_at TIMESTAMPTZ DEFAULT now() );networks: - "shc_{{ tenant_name }}"timeout: 60on_failure: abortThe context: container is deliberate — we can’t exec into our
own web service for schema bootstrap (its container runs
python -m http.server, not psql). We spawn a throwaway
postgres:16-alpine container just for the psql call. See
lifecycle-hooks — Job contexts
for when each context applies.
Now wire it up in app.yaml:
# add to app.yamlhooks: post_install: - init-schemapost_install fires after compose up succeeds; by then the
integration is fully connected so
{{ integrations.postgres.database_url }} resolves. See
lifecycle-hooks for the full hook contract.
Step 6 — Add a backup converter
Section titled “Step 6 — Add a backup converter”We want shc backup create feedback to include a consistent
database dump. Create ~/my-apps/feedback/backup.yaml:
converters: - name: dump-db phase: capture context: container image: postgres:16-alpine entrypoint: - sh - -c - "pg_dump {{ integrations.postgres.database_url }} > /backup/feedback.sql" networks: - "shc_{{ tenant_name }}" output: /backup/feedback.sql timeout: 300
volumes: - name: static strategy: rsyncconverters[].phase: capture runs during the capture phase of
backup — after the stack is snapshot-stopped and before the restic
commit. The output: path is relative to the backup staging
directory, which becomes part of the archive.
See flow: backup/restore for where each converter phase fires.
To restore the dump on a restore, add a restore converter too:
# backup.yaml (add) - name: restore-db phase: apply # runs during restore, after volumes are back context: container image: postgres:16-alpine entrypoint: - sh - -c - "psql {{ integrations.postgres.database_url }} < /backup/feedback.sql" networks: - "shc_{{ tenant_name }}" timeout: 300Step 7 — Install and verify
Section titled “Step 7 — Install and verify”shc repo sync my-apps
shc install feedback \ --stack fb \ --repo my-apps \ --var hostname=feedback.local \ --var adminEmail=admin@example.com \ -i postgres=db.database:fbBreaking down the -i flag:
postgres=db.database:fb means: connect our postgres plug to
the database vSocket on the db stack, specifically the fb
instance. See
plugs-and-sockets — CLI spec.
If the fb instance of db’s database vApp doesn’t exist yet,
create it first:
shc install postgres.database --stack db --instance fbCheck the install:
shc stack listshc status fbshc logs --stack fbshc job runs fb # should show init-schema as completedcurl http://feedback.local/Getting the autogenerated admin password back
Section titled “Getting the autogenerated admin password back”shc vault get adminPassword --stack fbScope defaults to deployment — same tuple the install used.
Step 8 — Exercise the hooks
Section titled “Step 8 — Exercise the hooks”Re-install to trigger pre_uninstall + post_uninstall:
shc uninstall fb --yesWatch events:
shc -s fb eventsYou’ll see A200000STKUNI fire as the uninstall completes.
Step 9 — Exercise the backup
Section titled “Step 9 — Exercise the backup”shc backup create fb
# List backups:shc backup list fb
# Restore (will use the dump-db converter to re-seed schema)shc backup restore <backup-id> --stack fbStep 10 — Static validation while developing
Section titled “Step 10 — Static validation while developing”shc validate runs the same schema checks the install flow uses,
but without needing a running target:
shc validate ~/my-apps/feedback/This catches:
- missing required variables
- YAML parse errors
- plug/socket schema mismatches
- reference to undefined job names in hooks
- reference to undeclared services in
public_hosts/public_ports
Run it on every save while iterating.
What’s not in this tutorial
Section titled “What’s not in this tutorial”Intentionally omitted — deeper topics you’d reach for in production apps:
- Exposing a socket so other stacks can plug into yours — see
plugs-and-sockets with a
complete socket example from
apps/postgres/. - vApps — letting operators provision per-tenant sub-resources inside your stack. See vapps.
- Crons — scheduled jobs inside the stack. Add a
crons:map toapp.yaml:…wherecrons:nightly-cleanup: "0 3 * * *"nightly-cleanupis a*.job.yamlfile. See architecture/modules/schedule. - Multiple environments — same stack, different env
(
--env prod,--env staging) get separate data, separate secrets, separate DNS. The tutorial uses the default env throughout. - Multi-node scheduling —
--node <id>pins to a specific node;requires.runtimes: [swarm]restricts to swarm nodes. - Resource quotas —
requires.storage:whitelists storage providers;shc quotatunes per-tenant limits. See architecture/modules/quota.
Full final app.yaml
Section titled “Full final app.yaml”For reference, everything in one place:
name: feedbackversion: "1.0.0"description: Simple feedback-form app with Postgres backendcategory: productivitytags: - feedback - forms
requires: runtimes: [compose, swarm]
public_hosts: web: "{{ vars.hostname }}"
vars: hostname@string!: Public hostname (e.g. feedback.example.com) adminEmail@email!: Administrator email address adminPassword: "!ref+vault://adminPassword?autogen&length=32"
hooks: post_install: - init-schemaDirectory after all steps:
~/my-apps/feedback/├── app.yaml├── compose.yaml├── postgres.plug.yaml├── init-schema.job.yaml├── backup.yaml├── files/│ └── app.py└── tests/ └── install.batsRelated
Section titled “Related”- App definition — the reference schema for every field
- Lifecycle hooks — the hook/job contract in full
- Plugs & sockets — the integration system used for the Postgres plug
- vApps — what
db.database:fbwas doing - Flow: install — what happens
internally when
shc installruns - Flow: backup/restore — where converters fire
- CLI reference: app — every command this tutorial used