Vedetta Core keeps all of its state in a single SQLite database on the
vedetta-data Docker volume (/data/vedetta.db inside the container). That
database holds your devices, events, durable findings and evidence links, identity and
finding-suppression audit histories, scan targets, whitelist/suppression rules, and your
API tokens — so backing it up is the one thing that matters most.
Before any update, take a backup. Migrations run automatically on start and can change the schema; a backup is your rollback path if an upgrade misbehaves.
Find your exact volume name first (Compose prefixes it with the project name):
docker volume ls | grep vedetta-data # e.g. vedetta_vedetta-dataThe community stack (--profile community) adds two more volumes worth backing
up the same way: telemetry-state and threat-network-data.
The central threat-network-data database also contains the curated device corpus,
its append-only audit trail, and every immutable published corpus release. Backing up
that one SQLite file therefore preserves both the community feed and corpus history.
The Core image ships the sqlite3 CLI, so you can take a consistent snapshot
while Core is running:
ts=$(date +%Y%m%d-%H%M%S)
docker compose exec backend sqlite3 /data/vedetta.db ".backup '/data/backup-$ts.db'"
docker compose cp backend:/data/backup-$ts.db ./vedetta-backup-$ts.db
docker compose exec backend rm /data/backup-$ts.dbAlso copy your config (it is not in the volume):
cp .env ./vedetta-env-$ts.bak # contains deployment config; keep it privateFor the central Threat Network database (community feed and curated corpus),
the runtime image also ships sqlite3 so the same online-backup guarantee is
available without stopping ingestion. The service reads the database location
from THREAT_NETWORK_DB (default /data/threat-network.db). Discover the value
from the running container so these commands back up the configured database,
including when an operator uses a custom path:
ts=$(date +%Y%m%d-%H%M%S)
threat_db=$(
docker compose --profile community exec -T threat-network \
sh -eu -c 'printf "%s" "${THREAT_NETWORK_DB:-/data/threat-network.db}"'
)
threat_backup_in_container="/tmp/threat-network-backup-$ts.db"
docker compose --profile community exec threat-network \
sqlite3 "$threat_db" ".backup '$threat_backup_in_container'"
docker compose --profile community cp \
"threat-network:$threat_backup_in_container" "./threat-network-backup-$ts.db"
docker compose --profile community exec threat-network \
rm -f "$threat_backup_in_container"If you override THREAT_NETWORK_DB, its parent directory must be on the
persistent threat-network-data mount (or another persistent mount). A path in
the container's writable layer will not survive container recreation.
ts=$(date +%Y%m%d-%H%M%S)
docker compose down
docker run --rm -v vedetta_vedetta-data:/data -v "$PWD":/backup alpine \
tar czf /backup/vedetta-data-$ts.tar.gz -C /data .
docker compose up -dStore backups off-box. Treat them as sensitive — they contain your network inventory and API tokens.
docker compose stop backend
docker compose cp ./vedetta-backup-<ts>.db backend:/data/vedetta.db
docker compose start backend
docker compose logs -f backend # confirm it opens the DB and migrations settledocker compose down
docker run --rm -v vedetta_vedetta-data:/data -v "$PWD":/backup alpine \
sh -c 'rm -rf /data/* && tar xzf /backup/vedetta-data-<ts>.tar.gz -C /data'
docker compose up -dVerify:
VED_TOKEN='<read-or-admin-token>'
VED_BACKEND_PORT="$(docker compose port backend 8080 | awk -F: 'END {print $NF}')"
# Poll readiness — /readyz answers 200 only once migrations applied + DB intact
# (503 otherwise); a single-shot curl right after `up -d` races cold startup.
for i in $(seq 1 60); do
curl -fsS --connect-timeout 1 --max-time 5 "http://localhost:${VED_BACKEND_PORT}/readyz" && break
[ "$i" -eq 60 ] && { echo "backend never became ready — check docker logs vedetta-backend"; exit 1; }
sleep 1
done
curl -fsS -H "Authorization: Bearer ${VED_TOKEN}" \
"http://localhost:${VED_BACKEND_PORT}/api/v1/status"
unset VED_TOKEN VED_BACKEND_PORTRestore the full Threat Network database, not an individual corpus table or
release row. The first procedure is a same-version data recovery: stop the writer,
retain a safety copy, replace the file, and resume the unchanged container.
Derive THREAT_NETWORK_DB from the Compose configuration with a no-dependency,
shell-only one-off container. This also works when startup validation has already
put the service into a restart loop:
threat_db=$(
docker compose --profile community run --rm --no-deps -T --entrypoint sh \
threat-network \
-eu -c 'printf "%s" "${THREAT_NETWORK_DB:-/data/threat-network.db}"'
)
docker compose --profile community stop threat-network
docker compose --profile community cp \
"threat-network:$threat_db" ./threat-network-before-restore.db
docker compose --profile community cp \
./threat-network-backup-<ts>.db "threat-network:$threat_db"
docker compose --profile community run --rm --no-deps --entrypoint sh \
threat-network -eu -c '
db=${THREAT_NETWORK_DB:-/data/threat-network.db}
rm -f "${db}-wal" "${db}-shm"
'
docker compose --profile community start threat-network
docker compose --profile community logs --tail=100 threat-network
curl -fsS http://127.0.0.1:9090/api/v1/status
curl -fsS http://127.0.0.1:9090/api/v1/device-corpus/manifestStartup revalidates the current immutable snapshot against its stored SHA-256, schema, revision, timestamp, and counts. A mismatch prevents the listener from starting, so do not ignore a validation failure.
For rollback across a Threat Network schema migration, do not use start on
the upgraded container. After replacing the full database as above, check out the
release that created the backup (or pin its exact image), then recreate the service:
git checkout <matching-release-tag>
docker compose --profile community up -d --no-deps --build --force-recreate threat-network
docker compose --profile community logs --tail=100 threat-network
curl -fsS http://127.0.0.1:9090/api/v1/device-corpus/manifestThis is the one-command safe path and it does everything below for you. It
snapshots the DB before the database can change: with a healthy backend
the old stack keeps serving through checkout and build, and the online
sqlite3 .backup is taken seconds before the container swap — so a
rollback discards almost no ingested data; with a down/crash-looping backend
the stack is stopped and a cold tarball is taken first. It then restarts
and verifies the result with PRAGMA foreign_key_check and
PRAGMA integrity_check. On any failure it exits non-zero with the
captured backend log and attempts rollback: if the upgraded stack already
ran (a migration that crash-loops the backend, a corrupt graph), it restores
the pre-upgrade snapshot into the volume and relaunches the previous version;
if the failure came earlier (e.g. a build error), the database was never
touched — the old stack just keeps running.
Rollback is attempted, not unconditionally completed: when finishing it
would itself risk the data, the script halts with the stack stopped, the
snapshot intact, and explicit instructions instead. That happens when
docker compose down fails (containers may still hold the volume), when the
snapshot cannot be restored into the volume, when the previous version cannot
be checked out or rebuilt, and in in-place mode with no known-good ref (next
paragraph). After a halt, recover manually with sections 2 and 4.
git fetch --tags
./scripts/upgrade.sh v0.1.0-beta.1 # upgrade to a pinned tag/commit
# ./scripts/upgrade.sh --from <good-ref> # rebuild current checkout in place
# ./scripts/upgrade.sh -y v0.1.0-beta.1 # non-interactive (e.g. over SSH)In-place rebuilds need
--fromfor full auto-rollback. Bare./scripts/upgrade.sh(no target ref) rebuilds the checkout you are on — if that version fails after startup, the failing code is the current checkout, so relaunching it would just re-run the failing migration. The script restores the DB snapshot and then halts with the stack stopped. Pass--from <known-good-ref>to give it somewhere safe to return to.
It snapshots the Core DB, a copy of .env, and best-effort telemetry-state
into ../vedetta-backups/upgrade-<timestamp>/ — a sibling of the repository,
deliberately outside it, so snapshots can never enter a Docker build context
or be affected by git checkout (override with VEDETTA_BACKUP_DIR). It holds
your DB and API tokens — keep it private. Keep each snapshot until the new
version has proven itself in daily use.
The script upgrades Core services only (the profile-less set: backend,
frontend, collector, telemetry). Community-profile services such as a locally
run threat-network are never rebuilt, restarted, or rolled back by it — they
keep their own data and runbook (docs/threat-network-operations.md). In
particular, upgrade.sh does not tar the live threat-network-data volume;
use the consistent online SQLite backup in section 1 for that database.
Crash-looping-backend caveat. Migrations run on boot and are fail-closed: a failure aborts startup (
log.Fatalf). When the backend is already down or crash-looping, the onlinesqlite3 .backup(Option A below) is not available — nothing is listening toexecinto. In that stateupgrade.shautomatically falls back to the cold volume tarball (Option B): it stops the stack and tars thevedetta-datavolume directly. This is the path that works mid-incident, so reach for the cold tarball (not the online backup) when recovering a Core that will not start.
If you upgrade by hand, prefer a tagged release over mutable main and
always back up first:
# 1. Back up (section 1).
# 2. Move to a released version rather than a moving branch:
git fetch --tags
git checkout <release-tag> # e.g. v0.1.0-beta.1
# 3. Rebuild and restart:
docker compose up -d --build
# 4. Verify health + that your data is intact:
VED_TOKEN='<read-or-admin-token>'
VED_BACKEND_PORT="$(docker compose port backend 8080 | awk -F: 'END {print $NF}')"
# Poll readiness — /readyz answers 200 only once migrations applied + DB intact
# (503 otherwise); a single-shot curl right after `up -d` races cold startup.
for i in $(seq 1 60); do
curl -fsS --connect-timeout 1 --max-time 5 "http://localhost:${VED_BACKEND_PORT}/readyz" && break
[ "$i" -eq 60 ] && { echo "backend never became ready — check docker logs vedetta-backend"; exit 1; }
sleep 1
done
curl -fsS -H "Authorization: Bearer ${VED_TOKEN}" \
"http://localhost:${VED_BACKEND_PORT}/api/v1/status"
unset VED_TOKEN VED_BACKEND_PORTWatch docker compose logs -f backend on first start after an update — schema
migrations run there.
scripts/update-core.sh/scripts/update-all.shrebuild in place and do not snapshot the DB. On a build failure they now stop rather than start stale images against a possibly-migrated schema; for a populated Core, preferscripts/upgrade.sh.
scripts/upgrade.shperforms this rollback automatically when an upgrade it ran fails verification. The steps below are for a manual recovery — when you upgraded some other way, when you want to roll back later, or when an automatic rollback halted (it deliberately stops, stack down and snapshot intact, rather than restore over live containers, relaunch a failing checkout, or continue past a failed restore — the script says exactly why).
If an update misbehaves, stop the upgraded Core before restoring. For any release that ran a database migration, including migrations 025–030 (asset findings, retained sensor identities, and temporal ARP evidence), restore the pre-update database together with the previous image. Do not run the older image against the expanded database: older device-merge, suppression, and retention code does not understand the new finding/evidence/audit relationships.
# 1. Stop Core and return to the previous version:
docker compose stop backend
git checkout <previous-release-tag>
# 2. Restore the pre-update database using section 2.
# 3. Rebuild/start the matching previous version and verify it:
docker compose up -d --build
VED_TOKEN='<read-or-admin-token>'
VED_BACKEND_PORT="$(docker compose port backend 8080 | awk -F: 'END {print $NF}')"
# Poll readiness — /readyz answers 200 only once migrations applied + DB intact
# (503 otherwise). A release that PREDATES /readyz serves 404 for it; for those,
# fall back to /healthz so a successful rollback isn't misreported as a failure.
for i in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 1 --max-time 5 "http://localhost:${VED_BACKEND_PORT}/readyz")
[ "$code" = "200" ] && break
[ "$code" = "404" ] && curl -fsS --connect-timeout 1 --max-time 5 "http://localhost:${VED_BACKEND_PORT}/healthz" > /dev/null && break
[ "$i" -eq 60 ] && { echo "backend never became ready — check docker logs vedetta-backend"; exit 1; }
sleep 1
done
curl -fsS -H "Authorization: Bearer ${VED_TOKEN}" \
"http://localhost:${VED_BACKEND_PORT}/api/v1/status"
unset VED_TOKEN VED_BACKEND_PORTBecause the pre-update backup predates the new migration, restoring it returns both the code and the database to a known-good, mutually-compatible state.
Migrations 025–030 are forward-only. Rollback across any of them requires the backup taken before updating; deleting its tables or reusing the migrated database with an older binary is not a supported rollback path.
The Threat Network corpus migration is likewise forward-only operationally.
Its state pointer is monotonic and must never be edited backward to "restore" a
historical release. To undo ordinary corpus content, publish a reviewed forward
correction/withdrawal. To recover the service or schema, restore the entire
pre-change Threat Network database (THREAT_NETWORK_DB, default
/data/threat-network.db) together with its matching binary, using the procedure
above.