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

# Back Up and Restore the Workspaces Container

> Create and restore a supported cold backup of the Workspaces data volume. Hot backups have not passed every release test.

Use a cold backup when you can stop Workspaces briefly. It copies the full
`/data` volume into one archive and is the supported default. Restore each
backup to a fresh volume, then open a document, check its version history, and
make a new edit.

## What the backup contains

The mounted `/data` directory contains all durable Workspaces data:

* SQLite metadata;
* bare Git repositories and document history;
* uploaded asset bytes;
* live-edit logs; and
* the ready marker used during boot.

Do not copy Docker's private host paths under `/var/lib/docker`. Mount the named
volume in a helper container instead.

## Create a cold backup

These examples use a container named `rooms`, a volume named `ws-data`, and a
backup directory at `/var/backups/workspaces`.

<Steps>
  <Step title="Stop Workspaces">
    ```bash theme={null}
    docker stop -t 15 rooms
    ```
  </Step>

  <Step title="Archive the full volume">
    ```bash theme={null}
    BACKUP_ID="$(date -u +%Y%m%dT%H%M%SZ)"
    BACKUP_PATH="/var/backups/workspaces/workspaces-${BACKUP_ID}.tar"
    if [ -e "$BACKUP_PATH" ]; then
      echo "Backup archive already exists" >&2
      exit 1
    fi

    docker run --rm \
      -v ws-data:/data \
      -v /var/backups/workspaces:/backup \
      alpine \
      tar -cf "/backup/workspaces-${BACKUP_ID}.tar" -C /data .
    ```

    Keep the command in one shell so `BACKUP_ID` has the same value throughout.
    The command stops before Docker runs if the destination exists.
  </Step>

  <Step title="Restart Workspaces">
    ```bash theme={null}
    docker start rooms
    curl -s http://127.0.0.1:8790/healthz
    ```
  </Step>
</Steps>

Copy the archive to storage outside the Docker host. Protect it as customer
data and test that you can restore it.

## Restore a cold backup

Never unpack a backup over the active volume.

<Steps>
  <Step title="Create a fresh volume">
    ```bash theme={null}
    RESTORE_VOLUME="ws-restored-$(date -u +%Y%m%dT%H%M%SZ)"
    if docker volume inspect "$RESTORE_VOLUME" >/dev/null 2>&1; then
      echo "Restore volume already exists" >&2
      exit 1
    fi
    docker volume create "$RESTORE_VOLUME"
    ```

    Keep the remaining restore commands in this shell. Do not reuse a volume.
  </Step>

  <Step title="Extract the archive">
    Set `BACKUP_ARCHIVE` to the exact filename created by the backup command.

    ```bash theme={null}
    BACKUP_ARCHIVE="workspaces-20260721T120000Z.tar"
    if [ ! -f "/var/backups/workspaces/$BACKUP_ARCHIVE" ]; then
      echo "Backup archive not found" >&2
      exit 1
    fi

    docker run --rm \
      -v "$RESTORE_VOLUME":/data \
      -v /var/backups/workspaces:/backup \
      alpine \
      tar -xf "/backup/$BACKUP_ARCHIVE" -C /data
    ```
  </Step>

  <Step title="Start the matching image">
    Start a new container with the same protected environment and the image
    version that created the backup. Mount `$RESTORE_VOLUME` at `/data`.

    ```bash theme={null}
    docker run -d --name rooms-restored \
      --env-file /secure/path/workspaces.env \
      -v "$RESTORE_VOLUME":/data \
      -p 8791:8790 \
      ghcr.io/plannotator/workspaces/workspaces-rooms:0.2.0
    ```
  </Step>

  <Step title="Verify the restore">
    Check `/healthz`, sign in or use a test API key, open a known document,
    inspect its versions, and make a new edit. Compare a known file with the
    bytes from before the backup.
  </Step>
</Steps>

## Create a hot backup

Use this method only when you cannot stop the container. Back up the SQLite
database first, then the live-edit logs, Git repositories, and assets. Do not
copy the live SQLite files directly.

<Warning>
  The release drill has not yet restored a hot backup that contains uploaded
  asset bytes. Use a cold backup as the supported default until that test passes.
</Warning>

```bash theme={null}
HOT_BACKUP_DIR="/var/backups/workspaces/hot-$(date -u +%Y%m%dT%H%M%SZ)"
if [ -e "$HOT_BACKUP_DIR" ]; then
  echo "Hot backup directory already exists" >&2
  exit 1
fi
mkdir "$HOT_BACKUP_DIR"

docker exec rooms node -e "
  const { DatabaseSync, backup } = require('node:sqlite');
  backup(new DatabaseSync('/data/metadata.sqlite', { readOnly: true }),
         '/tmp/metadata.sqlite').then(() => process.exit(0));
"
docker cp rooms:/tmp/metadata.sqlite "$HOT_BACKUP_DIR/metadata.sqlite"
docker exec rooms rm /tmp/metadata.sqlite

docker cp rooms:/data/yjs "$HOT_BACKUP_DIR/yjs"
docker cp rooms:/data/repos "$HOT_BACKUP_DIR/repos"
docker cp rooms:/data/assets "$HOT_BACKUP_DIR/assets"
```

## Restore a hot backup

Restore a hot backup into a fresh volume. Fix ownership before you write the
ready marker. Write that marker last so Workspaces cannot boot from a partial
restore. Set `HOT_BACKUP_DIR` to the exact directory created by the backup
command.

```bash theme={null}
HOT_BACKUP_DIR="/var/backups/workspaces/hot-20260721T120000Z"
if [ ! -d "$HOT_BACKUP_DIR" ]; then
  echo "Hot backup directory not found" >&2
  exit 1
fi
RESTORE_VOLUME="ws-restored-$(date -u +%Y%m%dT%H%M%SZ)"
if docker volume inspect "$RESTORE_VOLUME" >/dev/null 2>&1; then
  echo "Restore volume already exists" >&2
  exit 1
fi
docker volume create "$RESTORE_VOLUME"
docker run --rm \
  -v "$RESTORE_VOLUME":/data \
  -v "$HOT_BACKUP_DIR":/backup:ro \
  alpine sh -c '
    cp /backup/metadata.sqlite /data/metadata.sqlite &&
    cp -r /backup/yjs /data/yjs &&
    cp -r /backup/repos /data/repos &&
    cp -r /backup/assets /data/assets &&
    chown -R 1000:1000 /data &&
    touch /data/.plannotator-ready'
```

Then start the matching image. Open a known document, check its version history,
compare a known file with the pre-backup bytes, and make a new edit.

## If restore fails

| Result                                              | Check                                                                                                   |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| The container does not start with the restored data | Confirm the ready marker exists. For a hot restore, confirm that you wrote it after every other file.   |
| SQLite or the boot lock is not writable             | Change the restored files to user and group ID `1000`.                                                  |
| An older image reports unknown migrations           | Do not downgrade the restored data. Use the matching or a newer image.                                  |
| Documents work but assets are missing               | Recreate the backup. A valid hot backup must include `/data/assets`.                                    |
| `/healthz` returns `ok: true`, but writes fail      | Check free disk space and make a new document edit. The health check does not test every storage write. |

Use [Update or roll back the container](/workspaces/self-hosting/container/update-and-roll-back)
when you change image versions.

*Reviewed July 22, 2026. Maintained by the Plannotator documentation team.*
