Skip to content
SHC Docs

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_install hook
  • dumps the DB in a pre_backup converter so backups are consistent

Prerequisites: a working SHC install (see installation) and a Postgres stack to connect to:

Terminal window
shc install postgres --stack db

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:

Terminal window
mkdir -p ~/my-apps/feedback

Later, 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.


Create ~/my-apps/feedback/app.yaml:

name: feedback
version: "1.0.0"
description: Simple feedback-form app with Postgres backend
category: productivity
tags:
- 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_hosts is a top-level map of service_name → hostname, not nested under services:. The service name (web) must match a service in compose.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.

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: true

Key template variables:

  • {{ vars.* }} — everything declared in the vars: block (and auto-generated secrets resolve to their real values, not the ref+vault:// URI)
  • {{ integrations.postgres.* }} — the socket’s data dict, keyed by the plug name (our plug file was named postgres.plug.yaml → plug name postgres)
  • {{ stack }}, {{ environment }}, {{ tenant_name }}, {{ node_id }} — automatically injected

See app-definition — compose.yaml for the full template context.


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-schema
context: container
image: postgres:16-alpine
entrypoint:
- 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: 60
on_failure: abort

The 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.yaml
hooks:
post_install:
- init-schema

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


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: rsync

converters[].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: 300

Terminal window
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:fb

Breaking 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:

Terminal window
shc install postgres.database --stack db --instance fb

Check the install:

Terminal window
shc stack list
shc status fb
shc logs --stack fb
shc job runs fb # should show init-schema as completed
curl http://feedback.local/

Getting the autogenerated admin password back

Section titled “Getting the autogenerated admin password back”
Terminal window
shc vault get adminPassword --stack fb

Scope defaults to deployment — same tuple the install used.


Re-install to trigger pre_uninstall + post_uninstall:

Terminal window
shc uninstall fb --yes

Watch events:

Terminal window
shc -s fb events

You’ll see A200000STKUNI fire as the uninstall completes.


Terminal window
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 fb

Step 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:

Terminal window
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.


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 to app.yaml:
    crons:
    nightly-cleanup: "0 3 * * *"
    …where nightly-cleanup is a *.job.yaml file. 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 quotasrequires.storage: whitelists storage providers; shc quota tunes per-tenant limits. See architecture/modules/quota.

For reference, everything in one place:

name: feedback
version: "1.0.0"
description: Simple feedback-form app with Postgres backend
category: productivity
tags:
- 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-schema

Directory after all steps:

~/my-apps/feedback/
├── app.yaml
├── compose.yaml
├── postgres.plug.yaml
├── init-schema.job.yaml
├── backup.yaml
├── files/
│ └── app.py
└── tests/
└── install.bats