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 | Everything: projects, documents, table rows, users, rights, settings, and the stored (encrypted) connector credentials. This is the main backup |
| Install directory | Holds |
Uploaded files | Docker volume | 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 | Optional. If lost, the certificate is requested again, which can hit the certificate authority's rate limits |
Valkey | Docker volume | Not backed up. It holds short-lived state (locks, queues, real-time editing logs that expire after 24 hours) |
Agent traces | Docker volume | 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:
Dumps the database with pg_dump in custom format and checks that pg_restore can read the dump.
Packs the uploaded files into a .tgz (local storage only).
Copies both files to a local folder and uploads them to the bucket.
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.
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
Create a private bucket used only for these backups (for example my-backup-bucket). Do not allow public access.
Create an access key limited to that bucket, with permission to list, read, write and delete objects.
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 |
|---|---|
| The script |
| Settings and bucket credentials (mode 600) |
| Local dumps for the last |
| Run history (mode 640) |
Root's crontab |
|
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.
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.
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
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;"
Restore:
docker exec -i restore-test pg_restore -U postgres -d restore_test --no-owner --no-privileges < juxtaboard-db-YYYY-MM-DD.dump
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.
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.
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
Stop the application, so nothing writes during the restore. Keep the database and Valkey running.
cd /opt/juxtaboard && docker compose stop app
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;"
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;"
Restore:
docker compose exec -T db pg_restore -U codedocs -d codedocs --no-owner --no-privileges < /var/backups/juxtaboard/restore.dump
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"
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
Start the application:
docker compose start app
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
.envfile is also lost, restoring the database is not enough. Put the saved.envback (section 8) before starting the application. A newAUTH_SECRETsigns everyone out; a newCONNECTOR_ENCRYPTION_KEYmakes 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 |
|
|
Check the dump |
|
|
Restore |
|
|
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 |
|---|---|
| Section 5. The file must exist and define |
|
|
| Read the lines above it in the log. Check that the |
| The dump is incomplete (for example the disk filled up). Free space and run the script again |
| Check the endpoint, the region, the bucket name and the keys with the listing command in section 5 |
Rotation always says | Only names like |
| The AWS CLI is version 1. Install version 2 (section 4) |
Nothing runs at 03:00 |
|
No failure alert arrived | The script alerts only through |
Out of disk space | Dumps are kept locally for |