Backups

Backups

For: the administrator of a self-hosted Juxtaboard server. This guide covers self-hosted installations only. Covers: a nightly backup of the database and uploaded files to an S3-compatible bucket, rotation, how to prove a backup restores, and how to restore into production. You need: root access on the server, the Docker installation from , and a bucket on any S3-compatible storage.

The commands in this guide assume the Docker installation: a directory (below /opt/juxtaboard, replace it with yours) that holds docker-compose.yml and .env, with the services app, db (PostgreSQL) and valkey. If PostgreSQL runs directly on the host, read section 12 as well.

1. What to back up

What

Where it lives

Notes

Database

Docker volume codedocs_db

Everything: projects, documents, table rows, users, rights, settings, and the stored (encrypted) connector credentials. This is the main backup

.env file

Install directory

Holds AUTH_SECRET, CONNECTOR_ENCRYPTION_KEY, POSTGRES_PASSWORD and your mail and AI keys. Without CONNECTOR_ENCRYPTION_KEY, saved connector credentials are unreadable. Back it up separately (section 8)

Uploaded files

Docker volume codedocs_uploads, mounted at /app/public/uploads

Only when System → Config → Storage & Logs is set to Local filesystem. With S3-compatible, the files are in that bucket: turn on versioning there, and back it up with that provider's tools

TLS certificates

Docker volume codedocs_caddy_data

Optional. If lost, the certificate is requested again, which can hit the certificate authority's rate limits

Valkey

Docker volume codedocs_valkey

Not backed up. It holds short-lived state (locks, queues, real-time editing logs that expire after 24 hours)

Agent traces

Docker volume codedocs_traces

Not needed. Stored AI traces are deleted after a retention period anyway

If your installation does not set CONNECTOR_ENCRYPTION_KEY in the environment, the server generates the key and keeps it in Valkey and in the file .connector-encryption-key in the application's working directory. In that case, back up that file too.

2. What the script does

A script on the host, run from cron every night at 03:00 server time:

1.

Dumps the database with pg_dump in custom format and checks that pg_restore can read the dump.

2.

Packs the uploaded files into a .tgz (local storage only).

3.

Copies both files to a local folder and uploads them to the bucket.

4.

Deletes local files and bucket objects older than 7 days. The age comes from the date in the file name, not the upload time, so a stale re-upload cannot extend a file's life.

5.

Reports success or failure to a monitoring URL, if you set one.

A nightly backup means you can lose up to 24 hours of work. A standby database (see ) does not replace it: damage replicates to the standby, a dump does not.

3. Prepare the bucket

1.

Create a private bucket used only for these backups (for example my-backup-bucket). Do not allow public access.

2.

Create an access key limited to that bucket, with permission to list, read, write and delete objects.

3.

Note the endpoint URL, the region, the access key ID and the secret access key. Providers show the secret only once.

Any S3-compatible storage works: AWS S3, MinIO, Cloudflare R2 and others. For AWS S3 leave the endpoint empty and use the bucket's real region. Cloudflare R2, as one example, uses the region auto and an endpoint like https://<account-id>.r2.cloudflarestorage.com.

Careful: the key the script uses can delete objects, because rotation needs it. Whoever steals it can erase your history. If your storage supports object versioning or object lock, turn it on for this bucket.

4. Install the AWS CLI

Install the AWS command line client, version 2. It is only used as an S3 client; it works with any S3-compatible endpoint.

apt-get install -y unzip curl
cd "$(mktemp -d)" && curl -sSL "https://awscli.amazonaws.com/awscli-exe-linux-$(uname -m).zip" -o awscliv2.zip && unzip -q awscliv2.zip && ./aws/install
aws --version

Check: the output starts with aws-cli/2..

5. Write the configuration file

The script reads all its settings and secrets from one file, so the script itself holds no secrets.

install -m 600 /dev/null /etc/juxtaboard-backup.env

Open /etc/juxtaboard-backup.env in an editor and put in:

# Directory with docker-compose.yml and .env
INSTALL_DIR=/opt/juxtaboard
# Local folder for the dumps
BACKUP_DIR=/var/backups/juxtaboard
# How many days to keep dumps, locally and in the bucket
RETENTION_DAYS=7
# yes: also back up the uploads volume. Use no when files are stored in S3
BACKUP_UPLOADS=yes

S3_BUCKET=my-backup-bucket
# Leave empty for AWS S3
S3_ENDPOINT=https://s3.example.com
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
# A real region for AWS S3; "auto" for Cloudflare R2
AWS_DEFAULT_REGION=auto

# Optional. A monitoring URL of the "Healthchecks" kind: the script requests it
# on success and requests it with /fail appended on failure
PING_URL=

Check: ls -l /etc/juxtaboard-backup.env shows -rw------- and the owner root. If a value contains spaces or special characters, wrap it in single quotes.

Test the credentials before going further:

set -a; . /etc/juxtaboard-backup.env; set +a; aws ${S3_ENDPOINT:+--endpoint-url "$S3_ENDPOINT"} s3 ls "s3://$S3_BUCKET/"

Check: the command returns without an error. An empty bucket prints nothing.

6. Install the script

Save the following as /usr/local/sbin/juxtaboard-backup:

#!/usr/bin/env bash
# Nightly backup: PostgreSQL dump + uploaded files -> local folder and an
# S3-compatible bucket, with rotation by the date in the file name.
set -uo pipefail

CONF=/etc/juxtaboard-backup.env
[ -r "$CONF" ] || { echo "Missing $CONF" >&2; exit 1; }
set -a
. "$CONF"
set +a
: "${INSTALL_DIR:?set INSTALL_DIR in $CONF}"
: "${BACKUP_DIR:?set BACKUP_DIR in $CONF}"
: "${S3_BUCKET:?set S3_BUCKET in $CONF}"
RETENTION_DAYS=${RETENTION_DAYS:-7}
BACKUP_UPLOADS=${BACKUP_UPLOADS:-yes}

STAMP=$(date -u +%Y-%m-%d)
STARTED_EPOCH=$(date +%s)
LOG=/var/log/juxtaboard-backup.log
DB_DUMP="$BACKUP_DIR/juxtaboard-db-$STAMP.dump"
UPLOADS_TGZ="$BACKUP_DIR/juxtaboard-uploads-$STAMP.tgz"

AWS=(aws)
[ -n "${S3_ENDPOINT:-}" ] && AWS+=(--endpoint-url "$S3_ENDPOINT")

mkdir -p "$BACKUP_DIR" && chmod 700 "$BACKUP_DIR"
touch "$LOG" && chmod 640 "$LOG"

log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG"; }

ping_monitor() {
  [ -n "${PING_URL:-}" ] || return 0
  curl -fsS -m 10 --retry 3 -o /dev/null "${PING_URL}$1" >>"$LOG" 2>&1 || true
}

fail() {
  log "FAIL: $1"
  ping_monitor /fail
  exit 1
}

cd "$INSTALL_DIR" || fail "cannot enter $INSTALL_DIR"

# 1. Database dump, then prove pg_restore can read it
log "pg_dump -> $DB_DUMP"
if ! docker compose exec -T db pg_dump -U codedocs -Fc codedocs > "$DB_DUMP" 2>>"$LOG"; then
  rm -f "$DB_DUMP"
  fail "pg_dump failed - see $LOG"
fi
if ! docker compose exec -T db pg_restore --list < "$DB_DUMP" > /dev/null 2>>"$LOG"; then
  rm -f "$DB_DUMP"
  fail "the dump is not readable by pg_restore"
fi
log "  database dump: $(numfmt --to=iec "$(stat -c %s "$DB_DUMP")")"

# 2. Uploaded files (tar exit code 1 means "a file changed while reading": acceptable)
if [ "$BACKUP_UPLOADS" = "yes" ]; then
  log "uploads -> $UPLOADS_TGZ"
  docker compose exec -T app tar czf - -C /app/public/uploads . > "$UPLOADS_TGZ" 2>>"$LOG"
  if [ $? -gt 1 ]; then
    rm -f "$UPLOADS_TGZ"
    fail "packing the uploads failed - see $LOG"
  fi
  log "  uploads archive: $(numfmt --to=iec "$(stat -c %s "$UPLOADS_TGZ")")"
fi

# 3. Upload to the bucket
log "upload -> s3://$S3_BUCKET/"
for f in "$DB_DUMP" "$UPLOADS_TGZ"; do
  [ -f "$f" ] || continue
  "${AWS[@]}" s3 cp --no-progress "$f" "s3://$S3_BUCKET/$(basename "$f")" >>"$LOG" 2>&1 \
    || fail "upload of $(basename "$f") failed"
done
log "  uploaded"

# 4. Rotate local files
LOCAL_DELETED=$(find "$BACKUP_DIR" -maxdepth 1 -type f -mtime +"$RETENTION_DAYS" \
  \( -name 'juxtaboard-db-????-??-??.dump' -o -name 'juxtaboard-uploads-????-??-??.tgz' \) \
  -print -delete | wc -l)
log "rotate local (older than $RETENTION_DAYS days): deleted $LOCAL_DELETED file(s)"

# 5. Rotate the bucket: the age comes from the date in the key, not the upload time
CUTOFF_EPOCH=$(date -u -d "$RETENTION_DAYS days ago" +%s)
LISTING=$("${AWS[@]}" s3 ls "s3://$S3_BUCKET/" 2>>"$LOG") || fail "cannot list the bucket"
REMOTE_DELETED=0
while read -r _ _ _ key; do
  [[ "$key" =~ ^juxtaboard-(db|uploads)-([0-9]{4}-[0-9]{2}-[0-9]{2})\.(dump|tgz)$ ]] || continue
  key_epoch=$(date -u -d "${BASH_REMATCH[2]}" +%s 2>/dev/null) || continue
  if [ "$key_epoch" -lt "$CUTOFF_EPOCH" ]; then
    "${AWS[@]}" s3 rm "s3://$S3_BUCKET/$key" >>"$LOG" 2>&1 && REMOTE_DELETED=$((REMOTE_DELETED + 1))
  fi
done <<< "$LISTING"
log "rotate bucket (older than $RETENTION_DAYS days): deleted $REMOTE_DELETED object(s)"

# 6. Summary
LOCAL_COUNT=$(find "$BACKUP_DIR" -maxdepth 1 -name 'juxtaboard-db-*.dump' | wc -l)
REMOTE_COUNT=$("${AWS[@]}" s3 ls "s3://$S3_BUCKET/" 2>/dev/null | grep -c 'juxtaboard-db-')
log "inventory: $LOCAL_COUNT local dump(s), $REMOTE_COUNT in the bucket"
log "OK ($(( $(date +%s) - STARTED_EPOCH )) s)"
ping_monitor ""

Make it executable:

chmod 755 /usr/local/sbin/juxtaboard-backup

Notes on how it works:

•

It talks to the database through docker compose exec in the install directory, as the database user codedocs on database codedocs, the names the shipped docker-compose.yml creates.

•

-Fc is the compressed custom format; it is the format pg_restore needs in section 10.

•

Files are named juxtaboard-db-YYYY-MM-DD.dump and juxtaboard-uploads-YYYY-MM-DD.tgz (UTC date). Only files with exactly these names are ever rotated, so other objects in the bucket are left alone.

•

With 7 days of retention you keep the last 7 dated days, and a file dated 7 days ago is deleted.

7. Run it once, then schedule it

/usr/local/sbin/juxtaboard-backup

Check: the output ends with OK (... s), and /var/log/juxtaboard-backup.log contains lines like these:

[...] pg_dump -> /var/backups/juxtaboard/juxtaboard-db-YYYY-MM-DD.dump
[...]   database dump: 3.7M
[...] uploads -> /var/backups/juxtaboard/juxtaboard-uploads-YYYY-MM-DD.tgz
[...]   uploads archive: 1.2M
[...] upload -> s3://my-backup-bucket/
[...]   uploaded
[...] rotate local (older than 7 days): deleted 0 file(s)
[...] rotate bucket (older than 7 days): deleted 0 object(s)
[...] inventory: 1 local dump(s), 1 in the bucket
[...] OK (12 s)

List the bucket to confirm the objects arrived:

set -a; . /etc/juxtaboard-backup.env; set +a; aws ${S3_ENDPOINT:+--endpoint-url "$S3_ENDPOINT"} s3 ls "s3://$S3_BUCKET/"

Now add the cron entry, as root:

(crontab -l 2>/dev/null | grep -v juxtaboard-backup; echo "0 3 * * * /usr/local/sbin/juxtaboard-backup") | crontab -
crontab -l

Check: the list shows 0 3 * * * /usr/local/sbin/juxtaboard-backup. Cron uses the server's time zone. The next morning, check the last line of /var/log/juxtaboard-backup.log.

Careful: a backup you have never restored is a hope, not a backup. Do section 10 now, not later.

8. Back up the secrets separately

The script does not copy .env, on purpose. A dump contains connector credentials in encrypted form. If the key sat in the same bucket as the dump, the encryption would protect nothing.

Copy .env once after installing, and again after every change, to a place that is not the backup bucket: a password manager, an encrypted archive on another machine, or a secrets vault.

cp /opt/juxtaboard/.env ./env-backup-$(date +%F)

Check: you can open the copy on another machine and read CONNECTOR_ENCRYPTION_KEY and AUTH_SECRET from it. Delete the plain copy from the server afterwards.

Optionally, also copy the certificate volume (needs the HTTPS profile that ./install.sh --domain enables):

cd /opt/juxtaboard && docker compose exec -T caddy tar czf - -C /data . > caddy-data-$(date +%F).tgz

9. Where the files end up

Path

Content

/usr/local/sbin/juxtaboard-backup

The script

/etc/juxtaboard-backup.env

Settings and bucket credentials (mode 600)

/var/backups/juxtaboard/

Local dumps for the last RETENTION_DAYS days (mode 700)

/var/log/juxtaboard-backup.log

Run history (mode 640)

Root's crontab

0 3 * * * /usr/local/sbin/juxtaboard-backup

10. Test a restore

Do this once right after setup, and again after each major upgrade. The safest place is a different machine with Docker; nothing there can touch production.

1.

Download the latest dump (replace the date):

aws --endpoint-url https://s3.example.com s3 cp s3://my-backup-bucket/juxtaboard-db-YYYY-MM-DD.dump .

Omit --endpoint-url for AWS S3. Use the same access key variables as in section 5.

2.

Start a scratch PostgreSQL with the same extensions the application needs. Use a major version at least as new as the server's (the shipped installation uses 17):

docker run -d --name restore-test -e POSTGRES_PASSWORD=scratch -p 127.0.0.1:55432:5432 pgvector/pgvector:pg17
3.

Create the database and its extensions:

docker exec restore-test psql -U postgres -c "CREATE DATABASE restore_test;"
docker exec restore-test psql -U postgres -d restore_test -c "CREATE EXTENSION IF NOT EXISTS vector;" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
4.

Restore:

docker exec -i restore-test pg_restore -U postgres -d restore_test --no-owner --no-privileges < juxtaboard-db-YYYY-MM-DD.dump
5.

Count what came back:

docker exec restore-test psql -U postgres -d restore_test -c "SELECT (SELECT count(*) FROM pg_tables WHERE schemaname = 'public') AS tables, (SELECT count(*) FROM pg_indexes WHERE schemaname = 'public') AS indexes, (SELECT count(*) FROM users) AS users, (SELECT count(*) FROM projects) AS projects, (SELECT count(*) FROM documents) AS documents, (SELECT count(*) FROM data_table_rows) AS data_table_rows, (SELECT max(created_at) FROM users)::text AS latest_user_at;"

Check: run the same query on production (docker compose exec -T db psql -U codedocs -d codedocs -c "..." from the install directory). tables and indexes must match exactly. The other counts must be close; production may be higher by whatever was added since the dump. latest_user_at must be a recent date. pg_restore may print warnings; errors that stop the restore are the problem.

6.

Remove the scratch database:

docker rm -f restore-test

Quicker, less isolated: restore into a second database in the production container. This shares memory with the live database, so do it only on a quiet server.

cd /opt/juxtaboard && docker compose exec -T db createdb -U codedocs restore_test
docker compose exec -T db psql -U codedocs -d restore_test -c "CREATE EXTENSION IF NOT EXISTS vector;" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
docker compose exec -T db pg_restore -U codedocs -d restore_test --no-owner --no-privileges < /var/backups/juxtaboard/juxtaboard-db-YYYY-MM-DD.dump

Run the count query as above with -d restore_test, then:

docker compose exec -T db dropdb -U codedocs restore_test

11. Restore into production

Use this when the live database is lost or corrupt. Everything written after the dump is lost; with the nightly schedule that is up to 24 hours.

Careful: restore with an application image of the same version as the dump or newer. The application applies its database migrations when it starts, so a newer image upgrades an old dump. A dump taken after an upgrade cannot be used with an older image.

1.

Get the dump onto the server (from the local folder, or from the bucket):

set -a; . /etc/juxtaboard-backup.env; set +a; aws ${S3_ENDPOINT:+--endpoint-url "$S3_ENDPOINT"} s3 cp "s3://$S3_BUCKET/juxtaboard-db-YYYY-MM-DD.dump" /var/backups/juxtaboard/restore.dump
2.

Stop the application, so nothing writes during the restore. Keep the database and Valkey running.

cd /opt/juxtaboard && docker compose stop app
3.

Rename the damaged database instead of dropping it, in case you want to salvage rows later:

docker compose exec -T db psql -U codedocs -d postgres -c "ALTER DATABASE codedocs RENAME TO codedocs_damaged;"
4.

Create a fresh database with the extensions:

docker compose exec -T db psql -U codedocs -d postgres -c "CREATE DATABASE codedocs OWNER codedocs;"
docker compose exec -T db psql -U codedocs -d codedocs -c "CREATE EXTENSION vector;" -c "CREATE EXTENSION pg_trgm;"
5.

Restore:

docker compose exec -T db pg_restore -U codedocs -d codedocs --no-owner --no-privileges < /var/backups/juxtaboard/restore.dump
6.

Remove the real-time editing logs that Valkey still holds. Without this, edits from after the dump could be replayed on top of the restored documents when someone opens them:

docker compose exec -T valkey sh -c "valkey-cli --scan --pattern 'yjs:log:*' | xargs -r valkey-cli del"
7.

If the uploaded files are lost too, restore them before starting the application:

docker compose run --rm --no-deps -T --entrypoint tar app xzf - -C /app/public/uploads < /var/backups/juxtaboard/juxtaboard-uploads-YYYY-MM-DD.tgz
8.

Start the application:

docker compose start app
9.

Check the health endpoint (replace the port with APP_PORT from .env if you changed it):

curl -fsS http://127.0.0.1:3000/api/health

Check: the response contains "status":"ok" and "db":"ok". If you set HEALTH_TOKEN, send it in the X-Health-Token header. Then sign in and open a project you know well.

Once you are sure the restore is good, remove the damaged copy:

docker compose exec -T db psql -U codedocs -d postgres -c "DROP DATABASE codedocs_damaged;"

Careful: if the .env file is also lost, restoring the database is not enough. Put the saved .env back (section 8) before starting the application. A new AUTH_SECRET signs everyone out; a new CONNECTOR_ENCRYPTION_KEY makes saved connector credentials unreadable, and they must be entered again.

12. PostgreSQL installed on the host

If PostgreSQL does not run in Docker, change three things in the script and the commands above:

Where

Docker installation

PostgreSQL on the host

Dump

docker compose exec -T db pg_dump -U codedocs -Fc codedocs > file

PGPASSWORD=... pg_dump -h 127.0.0.1 -U <role> -Fc <database> -f file

Check the dump

docker compose exec -T db pg_restore --list < file

pg_restore --list file

Restore

docker compose exec -T db pg_restore ... < file

sudo -u postgres pg_restore -d <database> --no-owner --no-privileges file

Use your own role and database names. Stop and start the application with whatever manages it on your server instead of docker compose stop app and start app. Back up the uploads directory with your usual file backup tool and set BACKUP_UPLOADS=no. If you use more than one database role, also run pg_dumpall --globals-only as the postgres user and keep its output with the dump.

The application needs the vector and pg_trgm extensions in the database. The Docker installation uses a PostgreSQL image that already contains them.

13. Backups inside a project (for owners)

Project owners have their own, separate backup feature: Backups in the project menu. It copies each data table of one project to a CSV zip in storage the owner provides (S3-compatible or WebDAV), on a schedule.

It does not replace the backup in this guide:

•

It covers data table rows only. It does not include documents, forms, dashboards, automations, rights, portal settings, connectors or uploaded files.

•

It has no restore button; owners re-import the CSV files.

•

The server backup in this guide covers every project at once and can rebuild the whole installation.

Owners are told what it does and does not restore in . Three things in that feature depend on you:

•

The plan must allow it. Backups is available only on plans with the Scheduled project backups option. The shipped installation creates accounts on the self_hosted plan, which has backups on. Accounts created earlier keep their old plan; assign the Self-hosted plan to them in System → Billing, or tick Scheduled project backups on their plan under System → Config → Billing.

•

Email reports need outgoing mail. Report emails are sent through the SMTP settings of the installation (the SMTP_* variables or System → Config → Email).

•

Private destinations are refused. The server will not upload to loopback, private-network or cloud-metadata addresses, so an owner who points Backups at a MinIO on your local network sees an error when saving or testing. To allow such targets, set CONNECTOR_ALLOW_PRIVATE_HOSTS=true, or list the host or CIDR range in CONNECTOR_HOST_ALLOWLIST, in the app service's environment, and restart. The Compose file shipped with the installation passes only a fixed set of variables, so add them to its environment section. These variables apply to connectors as well.

14. Troubleshooting

Symptom

What to check

Missing /etc/juxtaboard-backup.env or set INSTALL_DIR in ...

Section 5. The file must exist and define INSTALL_DIR, BACKUP_DIR, S3_BUCKET

cannot enter ...

INSTALL_DIR is wrong. It must contain docker-compose.yml

pg_dump failed

Read the lines above it in the log. Check that the db container is running: docker compose ps

the dump is not readable by pg_restore

The dump is incomplete (for example the disk filled up). Free space and run the script again

upload of ... failed

Check the endpoint, the region, the bucket name and the keys with the listing command in section 5

Rotation always says deleted 0 although old objects exist

Only names like juxtaboard-db-YYYY-MM-DD.dump are rotated. Run the s3 ls listing and compare the names. Objects from an older script version with other names must be deleted by hand

aws: error: Unknown options: --no-progress

The AWS CLI is version 1. Install version 2 (section 4)

Nothing runs at 03:00

crontab -l shows the line? systemctl status cron is running? Run the script by hand: if it fails by hand it will fail from cron

No failure alert arrived

The script alerts only through PING_URL. Check that the URL is set, and that your monitor expects a call every day

Out of disk space

Dumps are kept locally for RETENTION_DAYS days. Lower it, or move BACKUP_DIR to a larger disk

•

•

•

•

(for project owners)