Database migration guide
Updated 2026-06-22
Last Updated: 2026-06-22 · Applies to: OpenWatch 0.2.0-rc series (Go single-binary)
This guide covers how OpenWatch's PostgreSQL schema is versioned, how migrations
are applied in production, and how to add a new migration. OpenWatch is a single
Go binary (/usr/bin/openwatch) that serves the REST API and the embedded React
UI over HTTPS on port 8443. It uses PostgreSQL only — there is no MongoDB, Redis,
Celery, Alembic, or container runtime involved in migrations.
For end-to-end install and configuration, see
docs/guides/INSTALLATION.md. This
document focuses specifically on the migration mechanism.
How migrations work
Migrations are plain SQL files embedded into the openwatch binary at build
time. The applier is pressly/goose running
in SQL-flavor mode.
| Aspect | Value |
|---|---|
| Database | PostgreSQL (UUID primary keys on most tables) |
| Migration tool | goose v3 (SQL flavor), embedded via go:embed |
| Migration directory | internal/db/migrations/*.sql |
| File naming | NNNN_description.sql (zero-padded ascending integer) |
| Version table | goose_db_version (created and managed by goose) |
| Applier code | internal/db/migrations/runner.go (Apply, Status) |
| CLI entry point | openwatch migrate (cmd/openwatch/main.go, cmdMigrate) |
Because the SQL files are compiled into the binary, the version of the schema a binary expects always travels with that binary. There is no separate migration package to install or path to configure at runtime.
Each migration file has an up section and a down section, delimited by goose annotations:
-- +goose Up
CREATE TABLE example (...);
-- +goose Down
DROP TABLE IF EXISTS example;The applier (migrations.Apply) only ever runs the Up direction
(goose.UpContext). The Down blocks exist for completeness and local
development; OpenWatch does not expose a rollback subcommand (see
Rollback).
Applying migrations in production
Run the migrate subcommand. It connects with the configured database DSN,
applies every pending Up migration, and prints the resulting version and the
list of embedded migration files.
sudo -u openwatch env $(cat /etc/openwatch/secrets.env | xargs) \
openwatch migrateThe DSN comes from OPENWATCH_DATABASE_DSN in /etc/openwatch/secrets.env (or
[database].dsn in /etc/openwatch/openwatch.toml). The command times out after
60 seconds, applies migrations idempotently (goose skips versions already recorded
in goose_db_version), and reports output like:
applying migrations against postgres://openwatch:***@127.0.0.1:5432/openwatch ...
current version: 46
migration files: 46
- 0001_initial.sql
- 0002_audit_events_taxonomy.sql
...
migrations appliedRun openwatch migrate after every package upgrade and before starting (or
restarting) the service, so the schema matches the binary. The systemd unit
(/usr/lib/systemd/system/openwatch.service, ExecStart=/usr/bin/openwatch serve)
does not run migrations on boot — serve and migrate are separate subcommands.
A typical upgrade sequence:
sudo systemctl stop openwatch
sudo dnf upgrade openwatch # or: sudo apt install --only-upgrade openwatch
sudo -u openwatch env $(cat /etc/openwatch/secrets.env | xargs) openwatch migrate
sudo systemctl start openwatchChecking the current schema version
The migrate subcommand prints the current version after applying. To inspect
the version table directly with psql:
psql "$OPENWATCH_DATABASE_DSN" -c \
"SELECT version_id, is_applied, tstamp FROM goose_db_version ORDER BY id DESC LIMIT 5;"The highest version_id with is_applied = true is the current schema version.
That number corresponds to the NNNN prefix of the last applied migration file.
Adding a new migration
-
Create a new file in
internal/db/migrations/named with the next ascending integer, for example0023_add_scan_findings.sql. Migration order is driven by the filename prefix, not by dates. -
Write the
UpandDownblocks using goose annotations:-- +goose Up CREATE TABLE scan_findings ( id UUID PRIMARY KEY, host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE RESTRICT, rule_id TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pass','fail','skipped','error')), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_scan_findings_host ON scan_findings (host_id); -- +goose Down DROP INDEX IF EXISTS idx_scan_findings_host; DROP TABLE IF EXISTS scan_findings; -
Follow the conventions already in the tree:
- Use
UUIDprimary keys for new tables. - Add indexes for foreign keys and common query columns.
- Make
DownreverseUpexactly, dropping indexes before tables and usingIF EXISTSguards. - Reference the owning behavioral spec in a comment when one exists (see
existing files such as
0012_transaction_log.sql).
- Use
-
Never edit a migration that has already shipped or been applied to a shared database. goose records each applied version; changing an applied file does not re-run it and leaves environments inconsistent. Add a new migration instead.
-
Verify locally:
go build ./... go test ./internal/db/... openwatch migrate # against a local dev PostgreSQLThe
internal/db/package includes tests that exercise the embedded migration set; run them before committing.
Rollback
There is no openwatch migrate down subcommand. The applier only runs the Up
direction. The supported recovery path for a bad migration in production is
restore-from-backup, not an automated downgrade.
Plan accordingly:
- Back up the database before applying migrations on a production system (see Backup before migrating).
- For schema mistakes, prefer a new forward migration that corrects the prior
one over any manual
DROP. - The
Downblocks in each file are for local development and may be applied by hand with thegooseCLI against a disposable database; they are not part of the production workflow.
Roadmap / not yet implemented: a first-class rollback subcommand (
openwatch migrate down) and a dry-run SQL preview are not present in the current binary. Do not assume they exist.
Backup before migrating
Take a logical backup with pg_dump before applying migrations to any
environment you cannot afford to lose:
pg_dump "$OPENWATCH_DATABASE_DSN" \
--format=custom \
--file="openwatch_$(date -u +%Y%m%dT%H%M%SZ).dump"Restore with pg_restore against a clean database if a migration must be
reverted:
pg_restore --clean --if-exists --dbname "$OPENWATCH_DATABASE_DSN" \
openwatch_20260610T120000Z.dumpRun pg_dump/pg_restore from the host (or a PostgreSQL client package) — there
is no container to exec into.
Troubleshooting
migrate fails to connect
Symptom: openwatch migrate: connect postgres://…: ….
-
Confirm PostgreSQL is reachable and the DSN is correct:
psql "$OPENWATCH_DATABASE_DSN" -c "SELECT 1;" -
Confirm
OPENWATCH_DATABASE_DSNis set in/etc/openwatch/secrets.envand uses the formpostgres://user:pass@host:port/db?sslmode=…. -
Validate the resolved config without touching the database:
sudo -u openwatch env $(cat /etc/openwatch/secrets.env | xargs) \ openwatch check-config
A migration fails partway
Symptom: openwatch migrate: up: … referencing a specific SQL error.
goose runs each migration in order and records a version only after it succeeds, so a failure leaves the database at the last fully-applied version. To recover:
-
Read the error and inspect the current version:
psql "$OPENWATCH_DATABASE_DSN" -c \ "SELECT version_id, is_applied FROM goose_db_version ORDER BY id DESC LIMIT 5;" -
Fix the offending migration file (only if it has never shipped) or write a corrective forward migration.
-
Re-run
openwatch migrate. Already-applied versions are skipped.
If the schema was left in an inconsistent state by a partially-executed statement, restore from the pre-migration backup.
Service starts but behaves as if the schema is old
Confirm the binary version and the applied schema version line up:
openwatch --version
psql "$OPENWATCH_DATABASE_DSN" -c \
"SELECT max(version_id) FROM goose_db_version WHERE is_applied;"If the version is behind the binary, run openwatch migrate and restart:
sudo -u openwatch env $(cat /etc/openwatch/secrets.env | xargs) openwatch migrate
sudo systemctl restart openwatch
journalctl -u openwatch -n 50 --no-pagerReference
| Item | Location |
|---|---|
| Migration files | internal/db/migrations/*.sql |
Applier (Apply, Status, List) | internal/db/migrations/runner.go, embed.go |
migrate subcommand | cmd/openwatch/main.go (cmdMigrate) |
| Config layering and DSN | internal/config/, docs/guides/INSTALLATION.md |
| systemd unit | packaging/common/openwatch.service |
| Install and upgrade flow | docs/guides/INSTALLATION.md |
| Compliance engine boundary | docs/KENSA_OPENWATCH_BOUNDARY.md |
OpenWatch's compliance engine, Kensa, runs SSH-based checks against native YAML rules. There is no separate scan-content schema in this database.