---
title: "Script"
description: "A command you wrote, run by your worker. The most useful source in the list."
url: "https://saved.sh/docs/backups/sources/script"
---

Runs a command on your worker's host and stores whatever it writes to a path we give it.

**Local only, permanently.** We do not execute customer commands in our cloud, and the
workflow is not registered there, so there is no configuration that could route one to us.

That constraint is what makes it useful. It is the escape hatch for every source we have no
connector for, running on your hardware, with no work from us and no new trust granted to
anyone.

## The contract [#the-contract]

One rule: &#x2A;*write your backup to `$SAVED_OUTPUT`.**

```bash title="/opt/saved/dump-everything.sh"
#!/usr/bin/env bash
set -euo pipefail

mongodump --archive --db=app > "$SAVED_OUTPUT"
```

|                   |                                                                       |
| ----------------- | --------------------------------------------------------------------- |
| `$SAVED_OUTPUT`   | A path the worker creates and passes in. Write the artifact here      |
| stdout and stderr | Captured as a **log**, kept for diagnostics, and never stored as data |
| Exit code         | Zero means success. Anything else fails the run                       |

<Callout type="error">
  **Piping to stdout does not work.** `mongodump --archive` on its own writes to stdout, and
  the run fails with `EmptyOutput`: the script exited zero without writing to the path.
  That is deliberate. Storing stdout as the artifact would make every stray `echo` corrupt
  the backup.
</Callout>

## Fields [#fields]

| Key           | Required | Notes                                           |
| ------------- | -------- | ----------------------------------------------- |
| `path`        | Yes      | Must be executable, unless `interpreter` is set |
| `args`        | No       | A list, one entry per argument                  |
| `interpreter` | No       | Runs `<interpreter> <path> <args...>`           |
| `working_dir` | No       | Directory to run in                             |

```yaml title="config.yaml"
backups:
  "<backup-uuid>":
    source_type: script
    source:
      path: /opt/saved/dump-everything.sh
      working_dir: /opt/saved
      args: ["--db", "app"]
```

`interpreter` is how you run a script that is not executable, or run a Python file without a
shebang:

```yaml
source:
  path: /opt/saved/export.py
  interpreter: /usr/bin/python3
```

Without an interpreter the file must be executable, and a non-executable path is refused with
a message saying to `chmod +x` it or set one.

## Patterns [#patterns]

Anything with a CLI is a backup source.

```bash
# MongoDB
mongodump --archive --db=app > "$SAVED_OUTPUT"

# An application directory
tar -czf "$SAVED_OUTPUT" -C /srv/app data config

# Two things at once
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
pg_dump -Fc app > "$tmp/app.dump"
cp /etc/app/config.yaml "$tmp/config.yaml"
tar -cf "$SAVED_OUTPUT" -C "$tmp" .

# A vendor CLI with its own export
vendor-cli export --format=json --out "$SAVED_OUTPUT"
```

The third pattern is the one worth internalising: **one run makes one artifact**, so if you
need several things together, tar them.

## Writing a script that fails properly [#writing-a-script-that-fails-properly]

The most common bug is a script that succeeds while producing nothing useful.

```bash
#!/usr/bin/env bash
set -euo pipefail          # fail on error, unset variable, or a broken pipe
```

`set -euo pipefail` is doing real work here. Without `pipefail`, `pg_dump | gzip > out` exits
zero when `pg_dump` fails, because `gzip` succeeded, and you get a valid gzip of nothing.

Three checks worth adding:

```bash
# Verify the thing you depend on exists
command -v mongodump >/dev/null || { echo "mongodump not installed" >&2; exit 1; }

# Verify the output is not suspiciously small
mongodump --archive --db=app > "$SAVED_OUTPUT"
[ "$(stat -f%z "$SAVED_OUTPUT" 2>/dev/null || stat -c%s "$SAVED_OUTPUT")" -gt 1024 ] \
  || { echo "dump is implausibly small" >&2; exit 1; }
```

The worker catches the zero-byte case for you. It cannot catch "the dump ran against an empty
database because the connection string was wrong", and your script can.

## Failure and diagnostics [#failure-and-diagnostics]

| Situation                                    | Result                                                    |
| -------------------------------------------- | --------------------------------------------------------- |
| Exit code zero, output written               | Success                                                   |
| Exit code zero, nothing written              | `EmptyOutput`, non-retryable                              |
| Non-zero exit                                | The run fails, with your stdout and stderr as the message |
| Path missing, not executable, or a directory | `InvalidSource`, non-retryable                            |

Output is truncated to 2000 characters in the run record, so print a useful message rather
than a stack trace of everything. Progress is reported as a byte count while the script runs,
so a long-running script is visibly alive.

## Environment and permissions [#environment-and-permissions]

The script inherits the worker's environment plus `SAVED_OUTPUT`, and runs as the **worker's
user**.

<Callout type="warn">
  That user needs to be able to do whatever the script does. Most "works when I run it, fails
  under the worker" reports are a script that depends on a shell profile, a `PATH` entry, or a
  `~/.pgpass` that belongs to your login rather than the service account.
</Callout>

Test it the way the worker will run it:

```bash
sudo -u saved SAVED_OUTPUT=/tmp/test.out /opt/saved/dump-everything.sh
ls -l /tmp/test.out
```

Secrets belong in the script's own environment or a file it reads, not in `args`, which are
visible in the process list.

## Disk [#disk]

The script writes to the worker's staging directory, and the artifact is then encrypted into
a second file before either is discarded. Budget roughly **twice** what your script produces,
per concurrent run. See [`local_temp_path`](/docs/workers/configuration#full-reference).
