apply
Reconcile a workspace against a YAML manifest.
View as Markdownsctl apply -f saved.yamlapply declares what a workspace should have. It creates what is missing, configures what
exists, and never deletes what the file omits.
Sources
sctl apply -f saved.yaml # one file
sctl apply -f ./config/ # every *.yaml and *.yml in the directory
sctl apply -f ./config/ -R # and below it
sctl apply -f - # stdin
sctl apply -f base.yaml -f prod.yaml # merged, in the order given-f is repeatable. Files merge in order, so a later file adds to an earlier one.
The manifest
Every key is snake_case.
workers:
- name: prod-worker-1
backups:
- name: prod-db
kind: local
source_type: postgres
worker: prod-worker-1
schedule: "0 2 * * *"
compression: true
retention:
keep_last: 10
expire_after: 90d
lock_for: 30d
encryption:
public_key: ./keys/prod.ascNames, not IDs. This is the one place names are the interface: worker: prod-worker-1
resolves to an ID at apply time, as do delivery destinations. That is what makes a manifest
portable between workspaces.
The kind decides which keys are legal
| Key | manual | local | cloud |
|---|---|---|---|
source_type | refused | required | required |
source / credentials | refused | refused | allowed |
worker | refused | required | refused |
schedule | refused | required | required |
The row worth reading twice: a local backup does not carry its own source. It names a
type and a worker, and the host, path and credentials live in that worker's own
config.yaml, keyed by backup id. They never reach us.
Fields
| Field | Applies to | Notes |
|---|---|---|
name | All | The key. Matching is by name |
kind | All | local, cloud or manual. Immutable |
source_type | local, cloud | Must be legal for the kind |
worker | local | A name from workers: or already provisioned |
schedule | local, cloud | Cron, UTC |
source | cloud only | The source's own fields, flat |
credentials | cloud only | Plain values, flat |
encryption | All | public_key, a path to an armored GPG public key |
compression | All | true or false. On means gzip |
retention | All | keep_last, expire_after, lock_for. Write-once |
delivery | All | destinations, skip_permanent, keep_permanent_on_failure |
A cloud source
- name: analytics
kind: cloud
source_type: postgres
schedule: "0 3 * * *"
source:
host: analytics.db.internal
port: 5432
database: app
ssl_mode: require
exclude_tables: [audit_log, sessions]
no_owner: true
credentials:
user: backup_ro
password: hunter2source and credentials are both flat, and the split between them is a hint, not a
rule. The backend decides what is secret by what the field is, so a password written under
source still goes to the vault and is never readable back. Write it wherever reads better.
The fields for each type are the same ones the dashboard's form shows, and the same ones a
worker's config.yaml uses. See Sources for the list per type.
Credentials are plain values
credentials:
user: backup_ro
password: hunter2apply runs on your machine, against a file you control, so a credential in it is yours to
protect the way you protect anything else on that disk.
A manifest is a file people commit. If yours carries credentials, keep it out of the repository, or template it and render the real file at apply time. Once a secret is in git history, rotating it is the only fix.
encryption.public_key is a path, not the key itself. A relative path resolves against
the manifest's own directory, not your working directory, so a manifest is runnable from
anywhere.
Validation happens first
The whole manifest is validated before any call is made, so a bad file changes nothing. Errors are reported together rather than one per run:
3 problem(s) in the manifest:
- backups[0] (prod-db): worker is required for kind local
- backups[1] (uploads): source type "script" is not available for kind cloud (want postgres | mysql | redis | s3 | web)
- backups[2] (archive): retention.expire_after "90" is not a duration (e.g. 90d, 720h)What is checked locally, before anything is sent:
- Required fields per kind, and fields that are illegal for a kind.
- Source types legal for the kind, including the local-only set.
- That every
worker:is declared in the file or already exists in the workspace. - Duplicate names, negative
keep_last, malformed durations. - Delivery combinations:
skip_permanentneeds a destination, andkeep_permanent_on_failureneedsskip_permanent. - The source itself, typed per source type. A misspelled key is named rather than accepted and quietly ignored:
2 problem(s) in the manifest:
- backups[0] (nightly): postgres source: unknown field hosts (want host, port, database, user, schema, password, ssl_mode, exclude_tables, no_owner, no_privileges)
- backups[1] (mirror): s3 source: bucket is requiredWhat it does
sctl apply -f saved.yamlworker prod-worker-1 created (0f2c9a1e-...)
⚠️ This token is shown once and cannot be retrieved again.
Put it in that machine's config.yaml as `token`:
sk_live_...
backup prod-db created (018f3c2a-...), state active
backup uploads configured (018f4d1b-...), state active| Situation | Result |
|---|---|
| Worker in the file, not in the workspace | Created, and its key printed once |
| Worker already exists | unchanged. No new key, nothing rotated |
| Backup not in the workspace | Created, then configured |
| Backup exists | Configured. The update is a merge |
Backup exists with a different kind | Refused. Kind is immutable |
| Anything in the workspace but not in the file | Left alone |
Watch the output when a worker is created. Its key is printed once, in the middle of the run,
and is not recoverable. In CI that means it lands in build logs, which is a good reason to
provision workers by hand rather than through apply.
apply never deletes
A resource that disappears from the file is left exactly as it was. Removing something is always explicit:
sctl backup delete "$BACKUP_ID"That asymmetry is deliberate. A file is easy to typo, easy to check out at the wrong revision, and easy to run from the wrong directory. None of those should be able to destroy a retention policy or an artifact.
If you want deletion to be declarative, that is a wrapper you write, with your own guard rails around it.
Re-running is safe
apply is idempotent for everything except worker creation. Running it twice configures the
same backups with the same values and reports them as configured again.
The one field that will refuse a second, different value is retention, which is write-once:
409 retention_immutableRe-sending the identical policy is accepted, so a manifest that carries retention can be applied repeatedly. Changing the numbers in the file and re-applying cannot work, and the error says so rather than silently ignoring it.
In CI
- name: Apply backup definitions
env:
SCTL_ACCESS_TOKEN: ${{ secrets.SAVED_API_KEY }}
SCTL_WORKSPACE_ID: ${{ vars.SAVED_WORKSPACE_ID }}
run: sctl apply -f ./backups/The key needs backups:read and backups:write. It cannot create workers, so declare
workers: only for workers that already exist, or the apply fails on the permission rather
than the manifest.
A cloud backup's credentials are in the manifest itself, so a pipeline that applies one needs the file rendered with its secrets at run time rather than committed with them.