A branchable, time-travelling MySQL-compatible database for WordPress. Development preview.
4.5K
A database for WordPress with branches and time travel. It speaks the MySQL protocol, and WordPress, wp-cli and mysqldump see it as MySQL 8.0, so you only change the database host.
Development preview. The data format is version 5 since 0.2.20, version 4 since 0.2.19, version 3 since 0.2.11, version 2 since 0.1.6 and version 1 before. A newer version opens older data as is and updates the version number when it starts; an older version refuses newer data. If a future version changes the format, that version converts the data when it starts. Pin a version and keep backups.
HeimDB is built only for WordPress. Views, triggers, stored procedures and full-text search are not supported and return explicit errors. So is SQL that reads or writes files on the server (LOAD_FILE(), SELECT ... INTO OUTFILE, LOAD DATA).
0.2.22, latest: linux/amd64, linux/arm64. OPTIMIZE TABLE, CHECK TABLE and REPAIR TABLE now return results instead of a syntax error. They do nothing (GC removes old versions, and Pebble checks its blocks when it reads them) and return OK (REPAIR returns a note, as InnoDB does), so WP-Optimize, Advanced Database Cleaner, WP-Sweep, wp-admin/maint/repair.php and wp db check / optimize / repair work. ANALYZE TABLE no longer reads the whole table. The data format is still version 5, the same as 0.2.20. Since 0.2.21: Prometheus metrics at /metrics with -metrics-addr.Older tags can still be pulled. Versions that need care when upgrading (see Usage and Configuration for features):
0.2.20: Data format version 5: older versions cannot open the data after the upgrade. 0.2.20 and newer primaries refuse 0.2.19 and older replicas.0.2.19: Data format version 4: older versions cannot open the data after the upgrade. 0.2.19 and newer primaries refuse 0.2.18 and older replicas.0.2.18: plugins activated on 0.2.17 or older may be missing indexes (SHOW INDEX ... WHERE returned wrong answers). Check with SHOW INDEX FROM table.0.2.16: For a post added on both sides with the same content, the row diff's parent_change is now insert. heimdb_branch_diff(name, '') is now an error; call heimdb_branch_diff(name) for the summary.0.2.15: The row diff adds three columns at the end, resolved_by, resolved_at and conflict_values; code that reads columns by position must read them.0.2.12: Refused merges return error numbers 51001 to 51009.0.2.11: Data format version 3: older versions cannot open the data after the upgrade. 0.2.11 and newer primaries refuse 0.2.10 and older replicas.0.2.8: Do not connect a 0.2.7 or older replica to a 0.2.8 or newer primary.0.2.5: On 0.2.4 and older, ALTER USER without IDENTIFIED cleared the password: reset the password of any account changed that way.0.1.6: Data format version 2: 0.1.5 and older cannot open the data after the upgrade. Upgrade the primary first, then rebuild the replica from a new backup.0.1.0: do not use. SQL could read and write files on the server. Fixed in 0.1.1.Pin a version (0.2.22), not latest. Before upgrading, take a backup (CALL heimdb_backup(...) or mysqldump). With a replica, upgrade the replica first, then the primary (also from 0.1.6 or newer to 0.2.11, 0.2.19 and 0.2.20: their data formats are versions 3, 4 and 5, but the records sent to replicas are unchanged). Only when the record format changes (0.1.5 or older to 0.1.6 or newer), upgrade the primary first, then the replica, or the upgraded replica cannot follow the old primary or go back. A primary and a replica check that they use the same record format version, and the replica stops instead of applying anything if they do not.
docker run -d --name heimdb -p 3306:3306 -v heimdb-data:/data \
-e HEIMDB_ROOT_PASSWORD='secret' -e HEIMDB_SITE_PASSWORD='site-secret' tsukinowa/heimdb:0.2.22
mysql -h 127.0.0.1 -P 3306 -u root -p wordpress
HeimDB runs as a non-root user (UID 65532) and does not change ownership at start, unlike the official MySQL image. Named volumes work as is; for a bind-mounted host directory, run chown -R 65532:65532 <dir> first, or HeimDB stops with open store: open /data/LOCK: permission denied.
On first start, HEIMDB_ROOT_PASSWORD creates root@'%' with all privileges, for the administrator. HEIMDB_SITE_PASSWORD creates wordpress@'%' with privileges on the production database wordpress only, if it does not exist yet. Connect WordPress as wordpress, not as root: a hole in a plugin would otherwise reach HeimDB's procedures (restore, branch deletion), user management and the other branches.
Save this as compose.yaml:
name: heimdb
x-db-host: &db-host
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: wordpress
services:
db:
image: tsukinowa/heimdb:${HEIMDB_VERSION:-0.2.22}
environment:
HEIMDB_ROOT_PASSWORD: ${HEIMDB_ROOT_PASSWORD:?set HEIMDB_ROOT_PASSWORD} # creates root@'%' on first start
HEIMDB_SITE_PASSWORD: ${HEIMDB_SITE_PASSWORD:?set HEIMDB_SITE_PASSWORD} # creates wordpress@'%' if missing
volumes:
- heimdb-data:/data
stop_grace_period: 1m # longer than HeimDB's -shutdown-timeout (30s)
wordpress:
image: wordpress:php8.3-apache
depends_on: [db]
ports:
- "${WP_PORT:-8080}:80"
environment:
<<: *db-host
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${HEIMDB_SITE_PASSWORD:?set HEIMDB_SITE_PASSWORD}
volumes:
- wordpress:/var/www/html
# On the first start of staging, copy production's files (languages, plugins, themes, uploads).
# Like the database branch, staging's files start as a copy. Later starts keep staging's files.
# STAGING_DB_PASSWORD is checked here because ${...:?} applies even when the profile is not used.
staging-files:
image: wordpress:php8.3-apache
profiles: ["staging"]
environment:
STAGING_DB_PASSWORD: ${STAGING_DB_PASSWORD:-}
entrypoint:
- sh
- -c
- |
[ -n "$$STAGING_DB_PASSWORD" ] || { echo "set STAGING_DB_PASSWORD" >&2; exit 1; }
[ -e /to/wp-includes/version.php ] || cp -a /from/. /to/
volumes:
- wordpress:/from:ro
- wordpress-staging:/to
restart: "no"
# A second WordPress on the branch "staging" (database wordpress@staging), connecting as a user
# limited to that branch
staging:
image: wordpress:php8.3-apache
profiles: ["staging"]
depends_on:
db:
condition: service_started
staging-files:
condition: service_completed_successfully
ports:
- "${STAGING_PORT:-8081}:80"
environment:
<<: *db-host
WORDPRESS_DB_NAME: wordpress@${STAGING_BRANCH:-staging}
WORDPRESS_DB_USER: staging
WORDPRESS_DB_PASSWORD: ${STAGING_DB_PASSWORD:-}
WORDPRESS_CONFIG_EXTRA: |
define('WP_HOME', 'http://localhost:${STAGING_PORT:-8081}');
define('WP_SITEURL', 'http://localhost:${STAGING_PORT:-8081}');
# Files live outside the database, so staging has its own volume (files are not merged)
volumes:
- wordpress-staging:/var/www/html
# wp-cli is the administrator's tool, so it connects as root (CALL heimdb_*, creating users)
cli:
image: wordpress:cli
profiles: ["cli"]
depends_on: [db]
user: "33:33"
environment:
<<: *db-host
WORDPRESS_DB_USER: root
WORDPRESS_DB_PASSWORD: ${HEIMDB_ROOT_PASSWORD:?set HEIMDB_ROOT_PASSWORD}
volumes:
- wordpress:/var/www/html
volumes:
heimdb-data:
wordpress:
wordpress-staging:
Start production and install WordPress (http://localhost:8080):
export HEIMDB_ROOT_PASSWORD='secret' HEIMDB_SITE_PASSWORD='site-secret'
docker compose up -d
docker compose run --rm cli wp core install --url=http://localhost:8080 --title=Site \
--admin_user=admin --admin_password='change-me' [email protected]
WordPress connects as wordpress. Only wp-cli (cli) uses root.
Staging (http://localhost:8081) connects as a user staging limited to its branch, so a compromised staging site cannot reach production. Create the branch and the user, then start staging. Staging does not start without STAGING_DB_PASSWORD.
export STAGING_DB_PASSWORD='staging-secret'
docker compose run --rm cli wp db query "CALL heimdb_branch_create('staging')"
docker compose run --rm cli wp db query "CREATE USER 'staging'@'%' IDENTIFIED BY '$STAGING_DB_PASSWORD'; GRANT ALL ON \`wordpress@staging\`.* TO 'staging'@'%'"
docker compose --profile staging up -d staging
After editing on staging, review the diff and merge it into production:
docker compose run --rm cli wp db query "CALL heimdb_branch_diff('staging')"
docker compose run --rm cli wp db query "CALL heimdb_branch_merge('staging')"
Files (languages, plugins, themes, uploads) are not merged. Staging starts with a copy of production's files. If you install or update a plugin, theme or language on staging, install the same on production before merging. Without the copy, staging would show English instead of your language, and opening its admin would deactivate missing plugins and switch the theme, which a merge would carry to production.
To start staging over from production's current database and files (the staging user stays):
docker compose --profile staging rm -sf staging staging-files
docker volume rm heimdb_wordpress-staging
docker compose run --rm cli wp db query "CALL heimdb_branch_delete('staging')"
docker compose run --rm cli wp db query "CALL heimdb_branch_create('staging')"
docker compose --profile staging up -d staging
HeimDB is operated with SQL CALL statements, run by an administrator (a user with all privileges).
CALL heimdb_branch_create('staging'); -- from production now
CALL heimdb_branch_create('test', 'main', '2026-10-08 09:00:00'); -- from a past time
CALL heimdb_branch_list();
CALL heimdb_branch_delete('test');
A branch appears as a database named wordpress@<branch>. Grant access to one branch only with a normal GRANT:
CREATE USER 'staging'@'%' IDENTIFIED BY 'pw' WITH MAX_USER_CONNECTIONS 20; -- concurrent connections
GRANT ALL ON `wordpress@staging`.* TO 'staging'@'%';
CALL heimdb_branch_limit('staging', '2G', '30s'); -- branch size and per-statement time limits
CALL heimdb_branch_diff('staging'); -- summary per table
CALL heimdb_branch_diff('staging', 'wp_posts'); -- per row ('*' for all tables)
CALL heimdb_branch_diff('staging', 'wp_posts', 0, 100); -- per row, 100 rows at a time
CALL heimdb_branch_merge('staging', 'dry_run'); -- check the merge and counts without writing
CALL heimdb_branch_merge('staging'); -- one commit into the parent
Rows are matched by primary key (option_name for wp_options) and compared column by column against the fork point. Columns changed only on the branch are merged, and columns changed only on production are kept. Environment-specific options (siteurl, home, transients, cron, ...), auto-drafts, revisions, comments and users are not merged. Table and index changes made by plugins (dbDelta) are merged. A merge that writes anything ends its result with a heimdb_restore call that undoes it.
If both sides changed the same column, the row is a conflict and the merge is refused until you choose a side:
CALL heimdb_branch_resolve('staging', 'wp_posts', 'ID=42', 'branch'); -- take staging's value
CALL heimdb_branch_resolve('staging', 'wp_posts', 'ID=42', 'parent'); -- keep production's value
CALL heimdb_branch_resolve('staging', 'wp_posts', '*', 'parent'); -- all conflicts in the table
The row is written as it appears in the id column of the diff ('ID=42').
Tables that a plugin rebuilds after a merge (such as Yoast SEO's indexes) can be left out of merges; production keeps them as they are:
CALL heimdb_merge_exclude('wp_yoast_%');
Block themes create a navigation post (wp_navigation) the first time the front page is shown. If both production and staging show it for the first time after the branch is created, the two navigation posts conflict. Keep production's with 'parent'.
SET time_zone = '+09:00'; -- times are read in the session time zone
-- read the past
SELECT ID, post_title FROM wp_posts AS OF '2026-10-08 09:00:00' WHERE ID = 123;
-- bring back one deleted post
INSERT INTO wp_posts SELECT * FROM wp_posts AS OF '2026-10-08 09:00:00' WHERE ID = 123;
-- list the rows changed since a time, in the order they changed
CALL heimdb_changes('main', '2026-10-08 09:00:00');
CALL heimdb_changes('main', '2026-10-08 09:00:00', 'wp_users,wp_usermeta'); -- only these tables
-- restore the whole site (comments and users are kept as they are now)
CALL heimdb_restore_preview('main', '2026-10-08 09:00:00');
CALL heimdb_restore('main', '2026-10-08 09:00:00');
CALL heimdb_restore('main', '2026-10-08 09:00:00', 'wp_options,wp_posts'); -- only these tables
A restore writes the old versions as a new commit, so history is not lost. Its last result row shows the CALL that undoes it. Old versions are kept for 30 days by default (-retention).
heimdb_changes shows each changed row with its id, the change (insert, update, delete), the changed columns, when it last changed, and the change watch description if any. Rows that change routinely (transients, cron, edit locks) are counted in the last row but not shown.
Without a table list, a restore keeps users. If users were added, had their password or email changed, or were given the administrator role after the restore time, the result (and the preview) has not restored rows saying so, and the log has a warning: an administrator added by an attacker would otherwise stay. Restore with '*' to bring users back too.
CALL heimdb_status(); -- disk usage, GC, connections, open transactions, replication, change watch
CALL heimdb_backup('before-upgrade'); -- consistent online backup into -backup-dir
CALL heimdb_gc(); -- remove versions older than the retention now
HeimDB logs a warning when a commit changes what usually changes when a WordPress site is taken over. It does not block anything.
level=warning msg="HeimDB: watch: [email protected] on wordpress: option default_role: \"subscriber\" -> \"administrator\""
level=warning msg="HeimDB: watch: [email protected] on wordpress: administrator role given: user ID 7 (wp_capabilities)"
siteurl, home, admin_email, users_can_register, default_role, template, stylesheet, active_plugins and the role definitions; users added or deleted, password and email changes (password hashes are never logged); the administrator role given to a user; on multisite, site_admins and network-activated plugins.wordpress@staging for a branch). Changes merged from a branch end with (merge of branch staging). Only committed changes are logged. Restores and replicas log nothing.CALL heimdb_status() shows the count since start (watch_changes) and the last one (watch_last_change, watch_last_change_at).wp search-replace and wp db import.heimdb_changes, check heimdb_restore_preview, then heimdb_restore (see Time travel). Files outside the database (plugins, uploads) are not restored.| Port | 3306 |
| Data | /data (use a volume) |
| User | nonroot (UID 65532) |
HEIMDB_ROOT_PASSWORD | Password of root@'%', created on first start only |
HEIMDB_SITE_PASSWORD | Password of the WordPress user (-site-user, default wordpress), created with privileges on the -db database only if it does not exist yet |
Arguments after the image name are passed to HeimDB:
docker run -d -p 3306:3306 -v heimdb-data:/data -v heimdb-backups:/backups \
-e HEIMDB_ROOT_PASSWORD='secret' tsukinowa/heimdb:0.2.22 \
-backup-dir /backups -slow-query 1s
| Flag | Default | Meaning |
|---|---|---|
-db | wordpress | Database name of production (the main branch) |
-site-user | wordpress | Name of the WordPress user created with HEIMDB_SITE_PASSWORD |
-retention | 720h | How long old versions are kept for AS OF and restore (0 keeps all) |
-gc-interval | 1h | How often old versions are removed |
-backup-dir | Where CALL heimdb_backup() writes (disabled if empty) | |
-slow-query | Log statements slower than this | |
-shutdown-timeout | 30s | How long to wait for running statements on stop |
-branch-procedures | false | Lets a user with only a branch's database call diff, resolve, changes and restore on the branch it is connected to, given GRANT EXECUTE (pass '' as the branch; also HEIMDB_BRANCH_PROCEDURES=1) |
-memory-limit | 80% of the container limit | Memory limit (like 4G; or set HEIMDB_MEMORY_LIMIT; 0 disables). Near it, a statement fails with error 1041 and its transaction is rolled back, instead of the process running out of memory |
-tls-cert, -tls-key | Enable TLS with these files (mount them readable by UID 65532) | |
-metrics-addr | Serve Prometheus metrics at /metrics on this address (like 0.0.0.0:9104; no authentication, so do not publish the port; read it from the same Docker network). Since 0.2.21 |
Read-only replicas and the remaining flags are documented in the source repository, which is not public yet.
docker stop (SIGTERM). Give it more time than -shutdown-timeout (docker stop -t 60, or stop_grace_period in Compose).Apache License 2.0. Copyright 2026 Tsukinowa Inc. The license notices of the dependencies are in /licenses in the image.
Content type
Image
Digest
sha256:1058921a9…
Size
27.9 MB
Last updated
1 day ago
docker pull tsukinowa/heimdb