> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getpostern.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Back up, export and erase

> Save the master key and the database in one file, restore them on another machine, export your cached data as JSON, and erase it.

<Info>
  **Before you start**

  * **A terminal on the machine Postern runs on.** Every command runs from the folder that holds
    `docker-compose.yml` and `.env`.
  * **Postern installed and running.** [Install Postern](/start/install) if it is not.
  * **The Console cannot back up.** Settings → **Your data** has a **Back up now** row, and its
    **Back up** button is disabled. The row says why:
    `Backup runs on the box — its status isn’t served to the Console.` Backup runs on the machine
    Postern runs on, and the Console is never told how it went.
  * **Postern never backs up on its own.** You decide when to run this, and where the copy goes.
  * **These commands are for macOS and Linux.** Postern runs under Docker, so it should also run
    on Windows. Nobody has tested that. This page has no PowerShell form.
</Info>

## Choose your path

| If you want to…                                                | Then                                                                     |
| -------------------------------------------------------------- | ------------------------------------------------------------------------ |
| protect yourself against the loss of this machine              | [Check who holds the master key](#key-custody), then work down to step 6 |
| move Postern to another machine, or recover from a lost one    | [Unpack the backup file on the new machine](#restore-unpack)             |
| read your cached data somewhere else                           | [Export everything as one JSON file](#export)                            |
| destroy cached data — one connection, one sector, or all of it | [Choose what to erase](#erase-scope)                                     |

<Steps>
  <Step title="Check who holds the master key" titleSize="h2" id="key-custody">
    A backup is one file that holds two things: the master key, and the Postgres database. Neither works
    without the other.

    Who holds the master key decides the next three steps. In the folder that holds
    `docker-compose.yml`, run:

    ```bash theme={"system"}
    grep -c '^PCI_MASTER_KEY=' .env
    ```

    | It prints                       | Who holds the key | What to do                   |
    | ------------------------------- | ----------------- | ---------------------------- |
    | `0`                             | Postern           | Follow every step below.     |
    | `1`, with a value after the `=` | You               | Skip steps 2, 3 and 8.       |
    | `No such file or directory`     | —                 | You are in the wrong folder. |

    An empty `PCI_MASTER_KEY=` counts as `0`. Postern reads an empty value as no value.

    **Postern holds it.** Postern makes a 32-byte key the first time it encrypts a credential. It is
    not made at first boot. A Postern that has connected nothing has no key yet. Postern keeps the key
    at `/app/.pci/master.key`, inside a Docker storage area named `pci_master_key`. The storage area
    is the key.

    **You hold it.** The value after the `=` is the key, and the storage area holds nothing to copy.
    Store that value in your password manager, away from the database copy. On the new machine, put
    `PCI_MASTER_KEY` — and `PCI_MASTER_KEY_ID`, if you set one — into `.env` before Postern's first
    boot there.

    `scripts/backup.sh` in the repository does not work on the shipped Docker layout. It exits with
    `refusing a keyless backup` and writes nothing.
    [Why, and what it does when you hold the key](/reference/vault#behaviours-you-will-meet)
  </Step>

  <Step title="Copy the master key out of the running container" titleSize="h2" id="capture-key">
    <Warning>
      Take the master key from the container, never from a `./.pci/master.key` file in your own folder.
      That file is a **different key**. Paired with a database copy from the container, it makes a
      backup file that restores cleanly and then decrypts nothing. Postern then refuses to start.
    </Warning>

    With Postern running, in the folder that holds `docker-compose.yml`, run:

    ```bash theme={"system"}
    mkdir -p ./backup-staging && chmod 700 ./backup-staging
    docker compose exec -T app cat /app/.pci/master.key > ./backup-staging/master.key
    chmod 600 ./backup-staging/master.key
    ```

    Check what you captured. On macOS:

    ```bash theme={"system"}
    wc -c < ./backup-staging/master.key
    shasum -a 256 ./backup-staging/master.key
    ```

    On Linux, the second line is `sha256sum ./backup-staging/master.key`.

    `wc -c` prints `32`. Anything else means you captured something other than the master key.

    Write the checksum into your password manager, beside the name of the backup file you make in
    step 5. You compare it at restore time. It proves a master key and a database copy came out of the
    same backup file. Do not keep it only here — this is the machine you may lose.
  </Step>

  <Step title="Read the master key when Postern is stopped" titleSize="h2" id="capture-key-stopped">
    Skip this step if step 2 worked.

    If the `app` container is not running — including after Postern refused to start — the same bytes
    sit in the storage area. Docker puts your folder's name in front of the storage area's name, so
    resolve the real name first:

    ```bash theme={"system"}
    KEYVOL=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/.pci"}}{{.Name}}{{end}}{{end}}' \
      "$(docker compose ps -aq app)")

    docker run --rm -v "$KEYVOL":/k alpine cat /k/master.key > ./backup-staging/master.key
    ```

    `echo "$KEYVOL"` prints a name that ends in `_pci_master_key`.

    Now run the two checks from step 2, and write the checksum down the same way.
  </Step>

  <Step title="Copy the database" titleSize="h2" id="dump-database">
    Postern can stay running for this. Optional: for a copy that nothing can change while it is made,
    stop Postern first with `docker compose stop app`, and leave the database running.

    In the same folder, run:

    ```bash theme={"system"}
    docker compose exec -T db pg_dump -U pci -d pci -Fc > ./backup-staging/pci.dump
    ```

    `-Fc` writes the compressed format that `pg_restore` reads back. No password is needed.
    [Why](/reference/vault#behaviours-you-will-meet)

    If you changed `POSTGRES_USER` or `POSTGRES_DB` in `.env`, use your own values in place of `pci`.

    If you stopped Postern, start it again now:

    ```bash theme={"system"}
    docker compose start app
    ```

    `ls ./backup-staging` prints `master.key` and `pci.dump`.
  </Step>

  <Step title="Pack both halves into one file" titleSize="h2" id="archive">
    <Warning>
      Anyone who gets this file can read every password inside it. The lines below set mode `0600`.
      Keep it that way.
    </Warning>

    In the same folder, run:

    ```bash theme={"system"}
    tar -czf "postern-backup-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" -C ./backup-staging .
    chmod 600 postern-backup-*.tar.gz
    rm -rf ./backup-staging
    ```

    `ls postern-backup-*.tar.gz` prints one filename. It holds two files, and it is a backup only while
    it holds both:

    ```text theme={"system"}
    master.key    32 raw bytes  (absent when you hold the key — that value lives in your .env)
    pci.dump      pg_dump -Fc
    ```

    `docker compose down -v` deletes the storage area that holds the master key. With a current backup
    file, that is recoverable.

    Postern prints `VAULT KEY IS NOT BACKED UP` to its log on every boot — after a good backup, and
    after a restore. It is a reminder, not an error, and no Console control clears it. To stop it,
    write the marker file yourself or hold the key yourself:
    [the backup banner](/reference/vault#the-backup-banner-and-how-to-stop-it)

    <Warning>
      Never rotate the master key between step 2 and step 4. A database copy older than a rotation needs
      the older master key. Take a fresh backup immediately before any rotation, and keep the older
      backup file until every connection reads `ok` again.
      [How a rotation and a backup drift apart](/reference/vault#rotating-the-master-key)
    </Warning>
  </Step>

  <Step title="Copy the backup file off the machine" titleSize="h2" id="copy-off">
    A backup that lives only on the machine it protects is not a backup.

    To another computer you can reach over SSH:

    ```bash theme={"system"}
    scp postern-backup-*.tar.gz <you>@<other-computer>:~/
    ```

    To an external drive: plug it in, then copy the file across with `cp` or your file manager.

    Set the file to mode `0600` on the far side. Record where you put it in your password manager,
    beside the checksum from step 2.
  </Step>

  <Step title="Unpack the backup file on the new machine" titleSize="h2" id="restore-unpack">
    The new machine needs Docker and the compose file first. Follow
    [Install Postern](/start/install) to the end of its step 2: install Docker, download
    `docker-compose.yml` into an empty folder, write a `POSTGRES_PASSWORD` line into `.env`. Compose
    refuses to start without that password. Add back any other variables you had set. Do not start
    Postern yet.

    Bring the backup file into that folder. From another computer that holds it:

    ```bash theme={"system"}
    scp <you>@<other-computer>:~/postern-backup-*.tar.gz .
    ```

    From an external drive, copy it across with `cp` or your file manager. Unpack it, and check the
    master key against what you recorded:

    ```bash theme={"system"}
    mkdir -p ./restore && chmod 700 ./restore
    tar -xzf postern-backup-<stamp>.tar.gz -C ./restore
    wc -c < ./restore/master.key
    shasum -a 256 ./restore/master.key
    ```

    On Linux, the last line is `sha256sum ./restore/master.key`.

    `wc -c` prints `32`, and the checksum equals the one in your password manager. A checksum that does
    not match means the two halves come from different points in time. Postern will refuse to start
    rather than half-work.
  </Step>

  <Step title="Put the master key in place before the first boot" titleSize="h2" id="restore-key">
    Skip this step if you hold the key. Put `PCI_MASTER_KEY`, and `PCI_MASTER_KEY_ID` if you used one,
    into `.env` instead.

    Boot is what opens the vault, so the master key must be in place before Postern's first start. In
    the folder that holds `docker-compose.yml`, run:

    ```bash theme={"system"}
    docker compose up -d db
    docker compose create app
    ```

    `docker compose create` makes the container but does not start it. That creates the `pci_master_key`
    storage area, so there is somewhere to put the key.

    Run `docker compose ps` and read the STATUS column of the `db` row. It says `health: starting`,
    then `healthy`. The check runs every 5 seconds and gives up after 10 tries. A `db` row that never
    reads `healthy` means Postgres did not start, and `docker compose logs db` says why.

    With the `db` row healthy, run:

    ```bash theme={"system"}
    KEYVOL=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/.pci"}}{{.Name}}{{end}}{{end}}' \
      "$(docker compose ps -aq app)")

    docker run --rm -i -v "$KEYVOL":/k alpine \
      sh -c 'cat > /k/master.key && chmod 600 /k/master.key && chown 1000:1000 /k/master.key' \
      < ./restore/master.key
    ```

    `1000:1000` is the `node` user Postern runs as. Postern cannot read a key it does not own.
  </Step>

  <Step title="Restore the database" titleSize="h2" id="restore-database">
    <Warning>
      **The first two lines destroy data.** They drop and recreate the database. Run them only when you
      restore **over** an existing Postern. Nothing they delete is recoverable except from another
      backup file. On a fresh machine the database is already empty: run the third line alone.

      **Every line ends in `</dev/null` on purpose.** `docker compose exec -T` reads standard input.
      Without `</dev/null` it swallows the rest of what you pasted, and everything after it never runs.
      Run a multi-step restore from a saved script file, not from one paste.
    </Warning>

    ```bash theme={"system"}
    docker compose exec -T db dropdb   -U pci --if-exists pci </dev/null
    docker compose exec -T db createdb -U pci pci </dev/null
    docker compose exec -T db pg_restore -U pci -d pci --no-owner < ./restore/pci.dump
    ```

    `pg_restore` finishes and names no failed item.
  </Step>

  <Step title="Start Postern and open a real credential" titleSize="h2" id="restore-verify">
    In the same folder, run:

    ```bash theme={"system"}
    docker compose up -d app
    ```

    Run `docker compose ps` until the STATUS column of the `app` row reads `healthy`. Then run:

    ```bash theme={"system"}
    docker compose logs app | grep -iE 'SecretAuth|MasterKey'
    curl -fsS http://localhost:8787/healthz
    ```

    The grep prints nothing, and `/healthz` answers `{"status":"ok"}`. That says Postern is running and
    its database is reachable. It does not say the master key matches the vault. A
    `VAULT KEY IS NOT BACKED UP` line is not a restore failure: the marker sat in the old volume and
    the restore made a new one. [Write it again](/reference/vault#the-backup-banner-and-how-to-stop-it).

    Now open a real credential:

    1. Open the Console at `http://localhost:8787`. If Postern runs on another computer,
       [forward the port over SSH](/start/remote-access#ssh-tunnel) first.
    2. In the left rail, click **Sources**.
    3. Click any connection except Apple Health. Apple Health is push-only: it has no **Sync now**
       button, and shows **Pause receiving** instead.
    4. On that connection's page, under **State**, click **Sync now**.
    5. Its status reads `ok`.

    A sync opens that connection's encrypted credential. Every connection back at `ok` is the only
    end-to-end proof that the master key and the database copy belong together. Delete `./restore`, and
    keep the backup file.

    <Note>
      `SecretAuthError` or `MasterKeyError` in the log means the master key and the database copy are
      from different points in time. So does a refusal to start that names a `key_id` it cannot open.
      Check `PCI_MASTER_KEY_ID` first: Postern looks rows up by that label, so the key can be right and
      only its label wrong. Otherwise capture the master key again from the old machine and restore again. If
      the old machine is gone and the key went with it, the data cannot be recovered.
    </Note>
  </Step>

  <Step title="Export everything as one JSON file" titleSize="h2" id="export">
    In the Console at `http://localhost:8787`, go to **Settings** → **Your data**. Find the
    **Export everything** row and click **Export**. It downloads one file named
    `postern-export-<date>.json`.

    The same bundle is a plain GET. Run it on the machine Postern runs on:

    ```bash theme={"system"}
    curl -fsS http://localhost:8787/api/export -o postern-export.json
    ```

    Only this computer can reach that address. Nothing on your Wi-Fi, and nothing on the internet, can.
    From another computer, [forward the port over SSH](/start/remote-access#ssh-tunnel) first.
    [Why the export answers here only](/reference/ports#the-console-has-no-authentication)

    The file holds a `manifest` and a `data` object.

    * `manifest` — the export timestamp, the sectors that had rows, and a row count per object. It also
      lists your connections: connection id, provider, sector, status, last sync time.
    * `data` — keyed by object: `finance.transaction`, `health.workout`, and for the sectors that hold
      a single object the sector's own name, `mail`, `calendar`, `contacts`, `home`. It holds every live
      row in full, including the `raw` payload the source returned.

    Rows the source itself has deleted are not in it. They are gone at the source, not your data.

    The export carries no credentials, no tokens and no access URLs. No route reads an export back in,
    so you cannot restore Postern from one. To undo an erase, reconnect the source and sync it again.

    Add `?sector=finance` to narrow the bundle to one sector's tables. The six sector names are
    `finance`, `mail`, `calendar`, `contacts`, `health` and `home`. Any other name answers `400` with
    `unknown sector: <name>`.

    Every export writes one row to Console → **The ledger** under the tool `export`. It carries the
    sector if you narrowed it, and the number of rows, never the rows themselves.
    [What one row holds](/reference/audit-log#what-one-row-holds)
  </Step>

  <Step title="Choose what to erase" titleSize="h2" id="erase-scope">
    <Warning>
      Erase deletes the rows themselves. It is not a mark the next sync undoes. There is no undo and no
      import: the export is a copy you can read, not a copy Postern can read back. One thing brings the
      data back — reconnect the source and sync it again.

      **On a Plaid connection, not even that.** Postern removes the Item at Plaid before it deletes a
      row. A later sync cannot resume; it needs a brand-new Link. That also stops the paid
      subscription those Items are billed under. The stop is best-effort, so check at Plaid afterwards
      if the subscription matters to you. An Item is one bank connection at Plaid; Link is Plaid's
      connect-your-bank window. [What Postern gets back from Plaid, and what it never sees](/reference/provider-sign-ins#plaid-never-shows-postern-your-bank-sign-in)
    </Warning>

    In the Console at `http://localhost:8787`, go to **Settings** → **Your data**. Find the
    **Erase data** row and click **Erase…**. It opens its own page.

    Pick one of the three scope cards. Each states what it reaches before anything happens:

    * **One connection** — `Drop one source's cached rows.` That connection stays connected, and a sync
      fills it again.
    * **A sector** — `Everything in one sector, all providers.` Those connections stay connected.
    * **Everything** — `Every sector, every connection.` Postern keeps its identity and your agent keys.
      The data is gone.

    **One connection** is selected first, as the least destructive. For **One connection** or
    **A sector**, pick the target from the list under the cards. Then arm the erase:

    1. Click **Export first**. The guard opens the moment you click, not when the download finishes —
       so watch that the file arrives.
    2. Type `ERASE` into the field labelled **Type ERASE to confirm**.
    3. Click **Erase this connection**, **Erase this sector** or **Erase everything**.

    If you change the target after that, both guards close again.

    At every scope the connector and credential rows survive, so you can sync again without a fresh
    connect. Plaid's credential is the exception: Postern marks it dead. The record of the erase
    survives too, and it never held the row values.
    [What one row holds](/reference/audit-log#what-one-row-holds)

    Erase is not disconnect, and it is not revoke.
    [The three are separate acts](/reference/agent-keys#what-a-revoke-does-not-touch)
  </Step>

  <Step title="Send the erase with the token Postern expects" titleSize="h2" id="erase-send">
    <Note>
      The Console sends `ERASE` for every scope. Postern checks the name of the thing you erase. So
      an erase from the Console can fail with `confirmation token does not match the requested scope`.
      Nothing was deleted when it does: Postern checks the token before it touches a row. The commands
      below are the form that goes through.
    </Note>

    Run these on the machine Postern runs on, or through the
    [SSH tunnel](/start/remote-access#ssh-tunnel) you use for the Console. Take the body for your scope.

    One connection:

    ```bash theme={"system"}
    curl -fsS -X POST http://localhost:8787/api/erase \
      -H 'content-type: application/json' \
      -d '{"scope":"connection","connectionId":"<connection-id>","confirm":"<connection-id>"}'
    ```

    One sector:

    ```bash theme={"system"}
    curl -fsS -X POST http://localhost:8787/api/erase \
      -H 'content-type: application/json' \
      -d '{"scope":"sector","sector":"finance","confirm":"finance"}'
    ```

    Everything:

    ```bash theme={"system"}
    curl -fsS -X POST http://localhost:8787/api/erase \
      -H 'content-type: application/json' \
      -d '{"scope":"all","confirm":"ERASE-ALL"}'
    ```

    For `<connection-id>`: the list on the Console's erase page shows only the first 8 characters. For
    the full value, open `postern-export-<date>.json` in a text editor, search for `connections`, and
    copy the `connectionId` value.

    It answers with the scope, a count of the rows deleted from each table, and the total. Tables that
    lost nothing are left out. So a short answer on a machine with a lot of data usually means you named
    a narrower scope than you meant. A connection id that is not yours answers `404` and deletes
    nothing.

    Running the same erase twice is safe. If an erase stops partway, run the same command again
    to finish it. [Why](/reference/vault#behaviours-you-will-meet)
  </Step>
</Steps>

## Confirm it works

* `wc -c` on the captured `master.key` prints `32`.
* The checksum you compute after you unpack the file equals the one in your password manager.
* The backup file holds both `master.key` and `pci.dump`. When you hold the key it holds `pci.dump`
  alone, and you can produce the `PCI_MASTER_KEY` value from where you stored it.
* The backup file also exists somewhere other than the machine Postern runs on.
* After a restore: a connection you clicked **Sync now** on reads `ok`.

## If something went wrong

| What you see                                                                     | What to do                                                                                                                                                          |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `refusing a keyless backup` from `scripts/backup.sh`                             | That script reads a host path; the key is inside a Docker storage area. Ignore the script and follow [step 2](#capture-key).                                        |
| `wc -c` prints anything other than `32`                                          | Delete `./backup-staging/master.key` and redo [step 2](#capture-key) — or [step 3](#capture-key-stopped) if `docker compose exec` cannot reach the `app` container. |
| `SecretAuthError`, `MasterKeyError`, or a refusal to start that names a `key_id` | Check `PCI_MASTER_KEY_ID` in `.env` first. If that is right, capture the master key again from the old machine, then redo [step 8](#restore-key) and step 9.        |
| `confirmation token does not match the requested scope`                          | Nothing was deleted. Postern wants the connection id, the sector name, or `ERASE-ALL` — not `ERASE`. Send the erase with the matching body: [step 13](#erase-send). |

## What you have now

One backup file at mode `0600`, with a 32-byte master key and a database copy that restore only
together. A second copy of that file sits off this machine. An export is separate: a readable JSON
snapshot of your cached data, with no credentials in it and no route that reads it back.

If you erased, the rows are gone and the record says so. A sync brings each source back — except a
Plaid connection, which needs a new Link.

## Next

<Columns cols={2}>
  <Card title="Set up remote access" href="/start/remote-access">
    Tailscale, and Funnel only if a hosted agent needs you · about 20 minutes, once · off by
    default, and both tiers can be taken back down.
  </Card>

  <Card title="Connect an agent" href="/start/connect-an-agent">
    A running Postern and the Console in front of you · a few minutes, plus a restart of whatever
    agent you connect · an agent key, shown once.
  </Card>
</Columns>
