Skip to content

Instantly share code, notes, and snippets.

@weitzman
Last active September 23, 2026 15:25
Show Gist options
  • Select an option

  • Save weitzman/b1d511d55fea8c69e35891cf7f56bf2d to your computer and use it in GitHub Desktop.

Select an option

Save weitzman/b1d511d55fea8c69e35891cf7f56bf2d to your computer and use it in GitHub Desktop.
GitHub Actions workflow that bakes a production DB dump into a DDEV db image (multi-arch, published to GHCR)
name: Build and publish a database image
on:
# Run manually in the GA UI on your branch when ad hoc builds are needed.
workflow_dispatch:
schedule:
- cron: '35 7 * * *'
# Optional repository Actions variables (Settings > Secrets and variables >
# Actions > Variables):
# DB_BUILD_RUNNER runner label; a large runner speeds up the import.
# DB_BASE_IMAGE DDEV db image to build on; match the `db` row of
# `ddev version` used by your team.
#
# The image is published to ghcr.io/<owner>/<repo>/database. A run on the
# default branch tags it :latest and with today's date; any other ref tags
# it with the sanitized branch name only, so ad hoc builds can't clobber
# :latest.
#
# The Dockerfile's builder stage produces base_db.zst (plain,
# architecture-independent data) natively on this one runner, and the
# final stage is pure COPY/metadata per platform, so a single buildx
# invocation covers both architectures with no emulation.
#
# No GHA build cache: the dump changes every run, so the expensive layer
# never hits, while exporting it would upload multi-GB blobs into the
# repo's 10 GB Actions-cache quota.
jobs:
build:
runs-on: ${{ vars.DB_BUILD_RUNNER || 'ubuntu-latest' }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- name: Compute image tags
run: |
image="ghcr.io/${GITHUB_REPOSITORY,,}/database"
if [ "$GITHUB_REF_NAME" = "${{ github.event.repository.default_branch }}" ]; then
tags="${image}:latest
${image}:$(date +'%Y-%m-%d')"
else
tags="${image}:$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9._-]/-/g')"
fi
{
echo "TAGS<<TAGS_EOF"
echo "$tags"
echo "TAGS_EOF"
} >> "$GITHUB_ENV"
# Replace this step with whatever produces a gzipped SQL dump of your
# production database at .github/database-ddev/dump.sql.gz.
- name: Get DB dump
run: |
curl -OL https://github.com/acquia/cli/releases/latest/download/acli.phar
chmod +x acli.phar
sudo mv acli.phar /usr/local/bin/acli
acli -n auth:login --key="${{ secrets.ACQUIA_API_KEY }}" --secret="${{ secrets.ACQUIA_API_SECRET }}"
db_dump=$(acli pull:db ${{ github.event.repository.name }}.prod default --no-interaction --no-import | tail -2l | xargs | sed 's/^.* //')
mv ${db_dump} .github/database-ddev/dump.sql.gz
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .github/database-ddev
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.TAGS }}
build-args: |
BASE_IMAGE=${{ vars.DB_BASE_IMAGE || 'ddev/ddev-dbserver-mariadb-11.8:v1.25.4' }}
# Both false to prevent an unknown/unknown arch being reported by GHCR.
provenance: false
sbom: false
# `labels` reach each per-platform image; `annotations` reach the
# multi-arch index, which is what GHCR's package page reads.
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.description=DDEV database image preloaded with ${{ github.event.repository.name }} production data
annotations: |
index:org.opencontainers.image.description=DDEV database image preloaded with ${{ github.event.repository.name }} production data
cleanup:
needs: build
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Delete older tagged database images
uses: snok/container-retention-policy@v3.0.1
with:
account: ${{ github.event.repository.owner.type == 'User' && 'user' || github.repository_owner }}
# A personal access token with delete:packages; GITHUB_TOKEN cannot
# delete organization packages.
token: ${{ secrets.GHCR_DELETE_TOKEN }}
image-names: ${{ github.event.repository.name }}/database
tag-selection: tagged
keep-n-most-recent: 30
cut-off: 1h
# NOTE: Do not add a `tag-selection: untagged` cleanup step here. The
# multi-arch push leaves the per-architecture child manifests
# intentionally untagged, referenced only by the tagged manifest
# list. snok/container-retention-policy has no manifest-list
# awareness, so an untagged sweep deletes those children, leaving
# tags pointing at missing content ("manifest not found" on pull).
# The tagged step above already bounds growth of meaningful versions.
# syntax=docker/dockerfile:1
# Bakes a production database dump into DDEV's stock database server image.
#
# The build imports the dump and writes /mysqlbase/custom/base_db.zst —
# the seed location DDEV reserves for derived images, which its entrypoint
# prefers over the stock starter archive /mysqlbase/base_db.zst when
# restoring into an empty datadir on first start. The datadir itself stays
# empty, so every consumer loads the data the same way:
#
# - DDEV's db service: `dbimage:` in .ddev/config.yaml points DDEV at
# this image, and the stock entrypoint restores the seed into the
# "database" volume whenever that volume is empty.
# - Standalone `docker run` (e.g. a GitHub Actions service container):
# the entrypoint restores it into the container's datadir on startup.
#
# The builder stage always runs natively on the build host
# ($BUILDPLATFORM); its product, base_db.zst, is architecture-independent
# InnoDB page data, so one native build feeds every target platform and
# `docker buildx build --platform linux/amd64,linux/arm64` needs no
# per-architecture runners and no emulation.
# The tag should match what the team's DDEV expects (the `db` row of
# `ddev version`); `ddev start` warns when a pinned dbimage was built
# from any other tag. database.yml overrides this via --build-arg from
# the DB_BASE_IMAGE Actions variable; keep its fallback in sync with the
# default below.
ARG BASE_IMAGE=ddev/ddev-dbserver-mariadb-11.8:v1.25.4
FROM --platform=$BUILDPLATFORM ${BASE_IMAGE} AS builder
RUN --mount=type=bind,source=dump.sql.gz,target=/tmp/dump.sql.gz <<'EOT'
set -eux -o pipefail
socket=/var/tmp/mysql.sock
# Restore the stock base database into the (empty) datadir, mirroring the
# restore /docker-entrypoint.sh performs on first boot, including its
# choice of backup tool (MySQL bases ship xtrabackup, MariaDB mariabackup).
backup=mariabackup
stream=mbstream
if command -v xtrabackup >/dev/null; then backup=xtrabackup; stream=xbstream; fi
mkdir -p /var/tmp/base_db
cd /var/tmp/base_db
zstdmt -dc --quiet /mysqlbase/base_db.zst | "${stream}" -x
"${backup}" --prepare --skip-innodb-use-native-aio --target-dir=/var/tmp/base_db
"${backup}" --datadir=/var/lib/mysql --copy-back --skip-innodb-use-native-aio --target-dir=/var/tmp/base_db
# Start a build-time server (socket only) and wait for it; the post-loop
# ping is the timeout backstop.
mysqld --user=root --skip-networking --socket="${socket}" &
pid=$!
for _ in $(seq 120); do
mysqladmin ping -uroot -proot --socket="${socket}" >/dev/null 2>&1 && break
kill -s 0 "${pid}"
sleep 1
done
mysqladmin ping -uroot -proot --socket="${socket}"
# Import into the standard DDEV database.
gunzip -c /tmp/dump.sql.gz | mysql -uroot -proot --socket="${socket}" db
# Write a base backup containing the imported data, in the same format as
# the starter archive (zstd-compressed backup stream), to the derived-image
# seed path the entrypoint prefers over the stock /mysqlbase/base_db.zst.
mkdir -p /mysqlbase/custom
"${backup}" --backup --stream="${stream}" --user=root --password=root --socket="${socket}" | zstdmt --quiet > /mysqlbase/custom/base_db.zst
mysqladmin shutdown -uroot -proot --socket="${socket}"
wait "${pid}"
EOT
# Final stage, per target platform: pure COPY/metadata, executes nothing.
# The datadir keeps its stock empty state, so the entrypoint performs the
# base_db.zst restore on first start.
FROM ${BASE_IMAGE}
COPY --from=builder /mysqlbase/custom/base_db.zst /mysqlbase/custom/base_db.zst
# No USER is declared: DDEV builds a derived image on top of this one and
# runs groupadd/useradd in it without switching to root first, so a
# non-root USER here breaks `ddev start`. The server still refuses to run
# as root: DDEV's compose sets the host user's uid explicitly, and other
# consumers (e.g. GHA service containers) should pass `--user mysql`.
# The inherited healthcheck tolerates only ~30s of initialization before
# reporting unhealthy. Restoring the production data on first start takes
# longer, so allow a generous start period (failures during it don't count
# against the retry limit; success ends it immediately).
HEALTHCHECK --interval=1s --timeout=70s --retries=70 --start-period=10m --start-interval=1s CMD ["/healthcheck.sh"]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment