Last active
September 23, 2026 15:25
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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