Skip to content
Be Useful.

Automated backups with Contentful & GitHub Actions

Traipsing through the process of setting up automated backups in Contentful, then verifying it works by restoring content to a fresh space.

Every post on this site lives in a headless CMS called Contentful. The words, the cover images and the categories, that kind of thing. And because it's headless, the rendering layer, the actual website, sits in a GitHub repo of its own.

The implication here is that the code has been backed up since day one... but the content hasn't. What would happen, I wondered, if I were to delete my space in a moment of fat-fingered idiocy at 2am on a Friday night? Mistakes do happen, after all.

So this week I built an automated backup workflow. Contentful already documents this process thoroughly, together with a couple of tutorials, and ships the tool that does the tricky part for you.

The useful question, then, wasn't how to back up a space. It was which parts of the documented approach to keep or swap out, and why. Once I had my answer, I ran a drill to test whether the backup would actually work as intended.

What the documentation already tells you to do

The boffins at Contentful maintain a command line tool with a space export command.

All you have to do is point it at a space and it produces a single JSON file holding the content model, every published entry, the assets, the locales, the tags, and the webhook definitions. The whole kit and caboodle. Assets are also exported as real image files alongside it.

A matching space import command can put all of that into a different space. That's the whole backup and restoration workflow in one tidy package.

But what if you want to automate the process? Just set it and forget it? The Contentful blog carries not one but two tutorials about that very topic.

  • One wraps the export in an AWS Lambda function on a CloudWatch schedule and drops the JSON into an S3 bucket.

  • The other is a CDK stack from an agency partner that does the same with less setup.

There's also a community tool that takes a third route to the same place.

The principle is identical in all three. Run the export on a timer. Stash the artefact somewhere durable. Restore with the import command.

I followed this guidance to the letter, more or less. The export command, the import command, the artefact, the restore path. I only changed two things to match the scope of this project: where the artefact goes and what triggers the run.

The detour, and the reason for it

Instead of backing up at daily intervals, the export is triggered by a Contentful webhook every time I publish, and committed to a private git repository.

Three reasons for this approach. First, it costs relatively nothing to use GitHub Actions to run the job, with the backups swept into a repository holding a few dozen megabytes of JSON and images.

Secondly the process is event-driven rather than time-driven, so the archive is current within a minute or two of any publish (instead of up to a day stale), with a nightly run kept as a backstop in the unlikely event the webhook should silently fail.

The third reason is that a bucket of dated JSON blobs only tells you what your space looked like on a given day. A git repository tells you what changed. Indulge me as I elaborate a bit.

Before committing, the job asks git whether anything is actually different, and if nothing is, it exits without committing. Run it a hundred times against an unchanged space and you get zero commits. That property is called idempotence. Without it, the repository would fill with identical snapshots and the history would be useless. But with it activated, every commit is a real editorial change with a date and a diff. The backup doubles as a publish log.

The obvious objection is that git is a poor fit for binary files, and a repository accreting image assets over time will bloat in a way an S3 bucket never would. That's true, at scale. If this were a media library of thousands of assets churning daily, I'd reach for the bucket. For a personal blog it's a rounding error, and I'll take the readable history in exchange.

With the workflow established, the archive lives in its own repository rather than inside the blog's.

Partly this is mechanical. Vercel redeploys the site on every push and doesn't inspect what changed, so a commit touching only a backup file would rebuild and ship a site identical to the one already live.

Mainly, though, it's because content should outlive whatever renders it. Front ends get rewritten. If the content sits inside the front end's repository, it's tethered to the front end's lifespan.

The whole pipeline...

The archive repository is private, so here is the workflow in full. A package.json file declares contentful-cli as its one dependency, and an export-config.json holds the space ID, the output directory, and the flags for downloading assets and pinning a stable output filename.

.github/workflows/sync.yml
name: Content sync

on:
  repository_dispatch:
    types: [contentful-publish]
  schedule:
    - cron: "17 3 * * *"
  workflow_dispatch:

concurrency:
  group: content-sync
  cancel-in-progress: true

permissions:
  contents: write

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the repo
        uses: actions/checkout@v4

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Export Contentful space
        run: npm run sync
        env:
          CONTENTFUL_MANAGEMENT_TOKEN: ${{ secrets.CONTENTFUL_MANAGEMENT_TOKEN }}

      - name: Commit if changed
        run: |
          git config user.name "content-sync-bot"
          git config user.email "actions@users.noreply.github.com"
          git add -A
          if git diff --cached --quiet; then
            LAST=$(git log -1 --format=%ct)
            NOW=$(date -u +%s)
            AGE=$(( (NOW - LAST) / 86400 ))
            echo "No content changes. Last commit was ${AGE} days ago."
            if [ "$AGE" -ge 45 ]; then
              date -u +%Y-%m-%dT%H:%MZ > .keepalive
              git add .keepalive
              git commit -m "chore: keepalive, no content changes in ${AGE} days"
              git push
            else
              echo "Within the 60-day activity window. Nothing to commit."
            fi
          else
            git commit -m "content sync: $(date -u +%Y-%m-%dT%H:%MZ)"
            git push
          fi

The repository_dispatch trigger is the webhook path, the cron is the backstop, and workflow_dispatch allows a manual run from the Actions tab. The concurrency block stops a burst of publishes from producing racing runs. The final step is the diff gate and, inside it, the keepalive.

Wiring up the trigger

The workflow listens for a repository_dispatch event, but something has to send it. That's a Contentful webhook, pointed at GitHub's API, firing on publish, unpublish and delete.

The webhook POSTs to https://api.github.com/repos/<owner>/<repo>/dispatches with three things:

An Authorization: Bearer <token> header, using a fine-grained personal access token scoped to that one repository with read and write on Contents.

An Accept: application/vnd.github+json header.

And a custom payload naming the event the workflow waits for: { "event_type": "contentful-publish" }

One gotcha that had me scratching my head: GitHub also demands a User-Agent header, which Contentful doesn't send unless you add it explicitly.

... and the ways it broke

Pasted above is the version of the file that works. Getting there was another story.

1. The lockfile

The first run failed in eleven seconds. The workflow installs dependencies with npm ci, the strict install that reads a lockfile and refuses to guess. The repository had no lockfile. The fix was to generate one and commit it.

2. The missing User-Agent

The webhook came back with a 403 Forbidden from GitHub. A 403 with a valid token in the request reads like an authentication failure, but it's actually something else.

GitHub's API rejects any request that doesn't identify itself with a User-Agent header, and it rejects it before it looks at the credentials. Contentful doesn't send one by default. The response body states the actual cause. Adding one header to the webhook fixed it.

3. The 60-day expiry

GitHub's documentation states that in a public repository, scheduled workflows are automatically disabled after 60 days without repository activity. Only commits count. Workflow runs do not.

My archive repository is private, and whether the rule extends to private repositories is genuinely unclear. The official page says public. Third-party guides and community threads describe it hitting repositories generally, and the people it surprises tend to find out months later. While I couldn't resolve the contradiction, I could try to mitigate it.

Consider what a correctly working backup does in its steady state. It runs nightly, finds nothing to do, and commits nothing. That's the same pattern GitHub reads as an abandoned repository. If the rule applies, publishing nothing for two months switches the safety net off, with no error in the Actions tab and a single notification email.

The insurance is ten lines at the bottom of the .yml file. If the job finds no content changes and the last commit is more than 45 days old, it writes a timestamp to a file and commits that instead. In normal operation it never fires, and if the rule turns out not to apply to private repositories, it costs one labelled commit per quiet six weeks.

4. The self-inflicted snafu

A week in, the nightly run went red. AccessTokenInvalid: 401.

I'd revoked and reissued the Contentful management token for unrelated reasons and forgotten that this pipeline was holding a copy. The backup had simply stopped, and the only reason I noticed was the red cross in the Actions tab.

Rotating that token on a reminder, rather than waiting for a red cross, is now basic maintenance for this workflow.

Reading the limitations page

Contentful publishes what export and import do not cover, and the list is specific.

  • The target space must have the same default locale as the source.

  • Webhooks are exported without their credentials, so a restored space has webhooks that will not authenticate until the secrets are re-entered by hand.

  • Space memberships are not included.

  • Tags have to exist in the destination already.

  • And every entry is recreated under the token of whoever runs the import, so author history does not survive the trip.

None of those are flaws. All of them are things worth knowing before a restore rather than during one.

Assets, for example, are only downloaded if you pass --download-assets on the export, and on the way back in, the import only uses those downloaded files if you ask for that too.

download assets
contentful space import \
  --space-id <NEW_SPACE_ID> \
  --content-file export.json \
  --upload-assets \
  --assets-directory ./raw

Skip --upload-assets and the import rebuilds your assets by fetching them from the original space's CDN. The restore appears to succeed, and it hasn't proved anything – the images are coming from the space whose loss you were simulating. In a genuine disaster scenario those URLs would be dead.

Of the five limitations above, one of them stymied my restore on the first attempt. More on that below.

Running the drill

A backup that's never been used to restore a site is untested, and an untested backup leaves too much to chance. So before publishing this post, I ran a drill and "restored" the content for this site from an archived backup.

The scope of it is three small steps:

  1. Create a brand new empty space.

  2. Import the archive into it.

  3. Query the new space's API, check that the content is there, and that the images are being served from the new space rather than the old one.

That last check is the one that catches the asset trap. If a single image URL still carries the original space ID, the restore has silently called upon the space whose loss it's supposed to be simulating.

Wouldn't you know it, the drill failed on the first attempt because of one of the limitations listed above. I created the space and the CLI gave it a default locale of en-US, but my source space is en-GB. Consequently, the import refused to start. Not a partial import, not a corrupted space, a flat refusal with an error message naming both locales.

The fix is a flag at creation time.

default locale
contentful space create --name "restore-drill" --default-locale en-GB

After that, the second attempt went through.

The import took 188 seconds, of which 114 were assets. It reported 41 uploaded asset files, 41 published assets, 49 published entries, six content types, and both webhook definitions recreated without their secrets, exactly as the documentation says they will be.

A handful of rate limit warnings appeared along the way and the CLI handled them by backing off and retrying without intervention.

Then the check that mattered. I queried the new space for every post and its cover image, and read the URLs. Every image resolved to the new space. Not one URL anywhere in the response pointed at the original. The images in that space were rebuilt from the files sitting in a git repository, and the live space wasn't touched during the restore.

Caveats, or what the drill doesn't prove

A thing to be clear about: a restored space isn't the same as a restored site.

Wiring the front end to a backup would entail generating fresh delivery and preview tokens, putting the new space ID into the environment, and re-entering the webhook secrets that the export deliberately left behind.

None of that's difficult, but it's a sizeable effort that might require contingency planning all of its own.

What the drill proves, though, is the value of treating content as a portable asset. The copy and images exist independently of the platform holding it, and should disaster (or a fat finger) strike, there's an archive I can use as the basis for reconstruction.

How's that for peace of mind?

Plant cutting
gouache illustration, a leafy plant cutting rooting in a glass jar of water on a sunlit windowsill, fine white roots visible through the glass, the parent plant in a terracotta pot beside it, deep crimson and warm off-white palette, warm directional light, textured paper grain.

Read next

Kinds of blue: Design tokens for contrast and accessibility

I woke up today and decided to change the header colour from crimson to blue. Let's talk about design tokens, contrast and accessibility.

Pay it forward: Fork this blog template and make it yours

The code behind this site is now a template you can clone and deploy for yourself. A sneaky peek at the journey from a public repo to a forkable template.