---
title: "Manual backups"
description: "No source and no schedule. You produce the file, we archive it."
url: "https://saved.sh/docs/backups/manual"
---

A manual backup has no source and no schedule. You produce a file however you like and
submit it; everything after that is identical to a scheduled backup.

## What it is for [#what-it-is-for]

Manual exists for the cases the other two kinds cannot reach:

* **Systems we have no connector for**, and that you would rather not wrap in a `script`
  source.
* **Cadences we do not control.** A quarterly export, an end-of-year close, a snapshot taken
  before a migration.
* **Air-gapped or offline sources**, where the file is produced somewhere with no path to
  either your worker or our network.
* **One-off archives** that should live under the same retention and protection as
  everything else.

If the thing you want is "run my command on a schedule, on my hardware", that is a
[`script` source on a local backup](/docs/backups/sources/script), not a manual one.

## What manual backups do not take [#what-manual-backups-do-not-take]

A manual backup is defined by what it omits, and each omission is enforced rather than
ignored:

| Field                    | Result                                  |
| ------------------------ | --------------------------------------- |
| `source_type`            | Refused. There is no source             |
| `schedule`               | Refused. It runs when you submit        |
| `worker`                 | Refused                                 |
| `source` / `credentials` | Refused. There is nothing to connect to |

Everything else applies normally: compression, encryption, delivery, and retention all work
exactly as they do for a scheduled backup.

```yaml title="saved.yaml"
backups:
  - name: quarterly-export
    kind: manual
    retention:
      keep_last: 8
      expire_after: 1095d
    encryption:
      public_key: ./keys/archive.asc
```

A manual backup becomes active as soon as it is created. There is nothing else it needs.

## Submitting from sctl [#submitting-from-sctl]

One command does the whole flow.

```bash
sctl backup submit <backup-id> ./quarterly-export.tar.gz
```

```
Submitted ./quarterly-export.tar.gz as run manual-018f3c2a-.... post-backup is running.
```

Then watch it like any other run:

```bash
sctl run list --backup $BACKUP_ID
sctl artifact list --backup $BACKUP_ID
```

## Submitting from the API [#submitting-from-the-api]

Four steps. The bytes never pass through our API: you upload directly to object storage
using a presigned URL.

**1. Create the run.**

```bash
curl -fsS https://api.saved.sh/v1/runs \
  -H "Authorization: Bearer $SAVED_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"backup_id": "<backup-id>"}'
```

```json
{ "run_id": "manual-018f3c2a-...", "expires_at": "2026-08-08T11:14:02Z" }
```

The run is created and then **waits** for the bytes. `expires_at` is how long it waits.

**2. Ask for an upload target.**

```bash
curl -fsS https://api.saved.sh/v1/runs/<run-id>/upload-url \
  -H "Authorization: Bearer $SAVED_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"content_length": 5368709120}'
```

```json
{ "method": "PUT", "url": "https://...", "expires_at": "..." }
```

For a large file the response is `"method": "MULTIPART"` instead, with a `part_size` and
`part_count`. See [multipart](#multipart-uploads).

**3. Upload.**

```bash
curl -fsS -X PUT --upload-file ./quarterly-export.tar.gz "<url>"
```

**4. Confirm.**

```bash
curl -fsS https://api.saved.sh/v1/runs/<run-id>/confirm \
  -H "Authorization: Bearer $SAVED_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
        "checksum": "sha256:9f86d0...",
        "size_bytes": 5368709120,
        "filename": "quarterly-export.tar.gz"
      }'
```

Confirming is what releases the run. Until then nothing is processed, and an unconfirmed run
produces no artifact however completely the upload succeeded.

<Callout type="warn">
  **Upload and confirm are separate steps on purpose.** The upload goes straight to storage
  and we are not in that path, so a completed PUT tells us nothing on its own. Confirm is the
  only thing that says the bytes are all there and what they hash to.
</Callout>

### Multipart uploads [#multipart-uploads]

When the file is large enough, `upload-url` returns `"method": "MULTIPART"` with a
`part_size` and `part_count`.

| Step                          | Endpoint                                                              |
| ----------------------------- | --------------------------------------------------------------------- |
| Request part URLs, in batches | `POST /v1/runs/{run}/upload-parts`                                    |
| Upload each part              | `PUT` to the returned URL, keeping the `ETag`                         |
| Finish                        | `POST /v1/runs/{run}/upload-complete` with every part number and ETag |
| Give up                       | `POST /v1/runs/{run}/upload-abort`                                    |

Abort a failed multipart upload rather than abandoning it. Parts left behind occupy storage
until they are cleaned up.

## The checksum [#the-checksum]

The checksum you send is what the artifact record carries, and it is what you compare
against when you download the artifact back.

```bash
sha256sum ./quarterly-export.tar.gz
```

Send it as `sha256:<hex>`. It is computed over the file **as you uploaded it**, before any
compression or encryption we apply afterwards.

## Encryption [#encryption]

A manual artifact is encrypted by **us**, after upload, if the backup carries a public key.
This differs from a local backup, where encryption happens on your worker before anything
leaves.

<Callout type="error">
  If you need the file to be encrypted before it reaches our storage, encrypt it yourself and
  upload the ciphertext. A manual backup's plaintext exists on our infrastructure between
  upload and the encrypt step, exactly as a cloud backup's does.
</Callout>

Encrypting it yourself and also setting a key on the backup is harmless: you get a PGP
message inside a PGP message, at the cost of the second one compressing nothing.

## Retention applies normally [#retention-applies-normally]

Manual artifacts are swept by the same daily retention pass as everything else, under the
policy on the manual backup. `keep_last` counts submissions, and the newest artifact is never
expired.

A quarterly export with `keep_last: 8` and `expire_after: 1095d` keeps two years of quarters
by age and never drops below eight, whichever binds first.

## Next [#next]

<Cards>
  <Card href="/docs/api/runs" title="Runs API" description="The endpoints above, in full." />

  <Card href="/docs/backups/retention" title="Retention" description="How long a submitted artifact is kept." />

  <Card href="/docs/cli/backups" title="sctl backup" description="Every backup command." />
</Cards>
