Sign inSign up

tsukinowa/heimdb

By tsukinowa

•Updated 1 day ago

A branchable, time-travelling MySQL-compatible database for WordPress. Development preview.

Image
Databases & storage
0

4.5K

tsukinowa/heimdb repository overview

⁠HeimDB

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.

  • Time travel: read the data as of any past time, and restore a site to that time (a restore can be undone too).
  • Branches: create a staging copy of production instantly without copying data, review the diff, and merge it back. Comments that arrived on production in the meantime are kept.

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).

⁠Tags

  • 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.

⁠Quick start

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.

⁠WordPress with a staging site (Docker Compose)

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

⁠Usage

HeimDB is operated with SQL CALL statements, run by an administrator (a user with all privileges).

⁠Branches
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
⁠Diff and merge
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'.

⁠Time travel and restore
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.

⁠Status, backup and GC
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
⁠Change watch

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)"
  • Watched: the options 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.
  • Each line shows the user and the address of the connection, and the database (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).
  • Normal operation logs too: the WordPress install, user registration, the first login of each user after upgrading to WordPress 6.8 (password rehash), wp search-replace and wp db import.
  • If a line is unexpected: list what changed since shortly before it with heimdb_changes, check heimdb_restore_preview, then heimdb_restore (see Time travel). Files outside the database (plugins, uploads) are not restored.

⁠Configuration

Port3306
Data/data (use a volume)
Usernonroot (UID 65532)
HEIMDB_ROOT_PASSWORDPassword of root@'%', created on first start only
HEIMDB_SITE_PASSWORDPassword 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
FlagDefaultMeaning
-dbwordpressDatabase name of production (the main branch)
-site-userwordpressName of the WordPress user created with HEIMDB_SITE_PASSWORD
-retention720hHow long old versions are kept for AS OF and restore (0 keeps all)
-gc-interval1hHow often old versions are removed
-backup-dirWhere CALL heimdb_backup() writes (disabled if empty)
-slow-queryLog statements slower than this
-shutdown-timeout30sHow long to wait for running statements on stop
-branch-proceduresfalseLets 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-limit80% of the container limitMemory 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-keyEnable TLS with these files (mount them readable by UID 65532)
-metrics-addrServe 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.

  • Stop with docker stop (SIGTERM). Give it more time than -shutdown-timeout (docker stop -t 60, or stop_grace_period in Compose).
  • To restore from a backup, start HeimDB with the backup directory as its data directory.

⁠License

Apache License 2.0. Copyright 2026 Tsukinowa Inc. The license notices of the dependencies are in /licenses in the image.

Tag summary

Content type

Image

Digest

sha256:1058921a9…

Size

27.9 MB

Last updated

1 day ago

docker pull tsukinowa/heimdb