---
title: "apply"
description: "Reconcile a workspace against a YAML manifest."
url: "https://saved.sh/docs/cli/apply"
---

```bash
sctl apply -f saved.yaml
```

`apply` declares what a workspace should have. It creates what is missing, configures what
exists, and **never deletes what the file omits**.

## Sources [#sources]

```bash
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 [#the-manifest]

Every key is `snake_case`.

```yaml title="saved.yaml"
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.asc
```

**Names, 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 [#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: &#x2A;*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 [#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 [#a-cloud-source]

```yaml
- 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: hunter2
```

`source` 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](/docs/backups/sources) for the list per type.

### Credentials are plain values [#credentials-are-plain-values]

```yaml
credentials:
  user: backup_ro
  password: hunter2
```

`apply` 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.

<Callout type="warn">
  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.
</Callout>

`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 [#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_permanent` needs a destination, and
  `keep_permanent_on_failure` needs `skip_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 required
```

## What it does [#what-it-does]

```
sctl apply -f saved.yaml
```

```
worker 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**                           |

<Callout type="error">
  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`.
</Callout>

## apply never deletes [#apply-never-deletes]

A resource that disappears from the file is left exactly as it was. Removing something is
always explicit:

```bash
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 [#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_immutable
```

Re-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 [#in-ci]

```yaml title=".github/workflows/backups.yml"
- 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.

## Next [#next]

<Cards>
  <Card href="/docs/cli/backups" title="Backups" description="The imperative equivalent." />

  <Card href="/docs/cicd" title="CI/CD" description="Running this from a pipeline." />

  <Card href="/docs/backups" title="Backups in depth" description="What each field means." />
</Cards>
