Skip to content
L-04Journal / ArticleEL +0.00 m
Cloud & DevOps11 min read

GitHub Actions in Production, Part 6: What Is This Backup Actually Protecting Me From?

A workflow that zips the repository to S3 on every push, and the uncomfortable question underneath it: git already backs up your code. On provider independence as the real threat model, why the cadence was wrong, and why a backup you have never restored from is a hypothesis.

By Mohammed Mostafa · Published

AWSAmazon S3GitHub ActionsBackupDisaster RecoveryDevOpsCloud Security

This workflow zips the repository on every push to live and uploads it to S3 under a timestamped key with the short SHA appended. Twenty-eight lines. It runs reliably and it has never failed.

It's also the one where I had to argue with myself the hardest, because the obvious criticism is brutal and mostly correct: you already have a backup of your source code. It's called git, and it exists on every developer's machine and on GitHub's infrastructure. Zipping a git checkout and putting it in a bucket is, on the face of it, backing up the one thing that needs backing up least.

I still think there's a real case for this. It's just narrower than "backups are good," and getting to it meant being honest about what I was actually afraid of.

Introduction#

The YAML here is trivial and I'll walk through it quickly. The part worth reading is the reasoning, because the reasoning determines whether the YAML is worth running at all, and for a while mine wasn't.

Why this workflow exists#

The naive framing is "back up the code." That framing is wrong and it produces a workflow that costs money and provides nothing.

The framing I'd defend is provider independence. Git protects you against losing a file, a branch, or a laptop. It does not protect you against losing your GitHub account, and that isn't hypothetical:

An organisation gets suspended over a billing dispute or a suspected ToS violation, sometimes automatically, sometimes wrongly. An owner account gets compromised by someone with force-push rights and the ability to delete repositories. A sole maintainer leaves, their account is deactivated, and the repos go with it. Or there's a prolonged provider outage during an incident where you need to deploy right now.

In every one of those, a dated archive in a bucket inside your own AWS account, inside your own security boundary, turns a catastrophe into an annoyance. That's a real threat model and it's the one this addresses.

Two things fall out of stating it that plainly, and I didn't see either until I wrote it down.

Push-triggered is the wrong cadence for that threat. Provider independence needs a recent copy, not a copy per commit. Twenty pushes on a busy day produce twenty near-identical archives, and the difference between them is worth nothing against the risk being mitigated. A daily schedule serves the same purpose at a fraction of the cost. I built it push-triggered because push-triggered is the reflex, not because it followed from anything.

The bigger one: this is not a disaster recovery backup and I shouldn't describe it as one. There's no database in it. No user uploads. No environment configuration. If the server burns down tomorrow, this archive restores your code, which you could also have gotten from git, and not one byte of the state your users actually care about. Everything irreplaceable is outside this workflow's scope.

That distinction is the whole point of the article. A workflow called "backup" that people believe covers disaster recovery is worse than no workflow, because it manufactures confidence, and manufactured confidence only gets discovered during an actual disaster.

Architecture#

snippettext
  push: live
      │
      ▼
  actions/checkout            (shallow, depth 1 — no history)
      │
      ▼
  zip -r <name> . --exclude …
      │  name = <project>-YYYYMMDD-HHMMSS-<sha7>.zipexport via $GITHUB_ENV
      ▼
  aws s3 cp → s3://<bucket>/backups/<project>/<name>.zip

Linear, stateless, no dependencies beyond the preinstalled AWS CLI. All the interesting decisions are in naming and exclusion.

The key naming scheme is the part I'd keep unchanged:

snippettext
<project>-20260801-142317-a3f9c21.zip

Sortable by name, because YYYYMMDD-HHMMSS sorts lexicographically. Readable by a human. Traceable to an exact commit via the short SHA. When you're restoring under pressure, "which archive do I want" has to be answerable from an aws s3 ls listing alone, with no metadata lookup and no guessing, and this gets you that.

Prefixing with backups/<project>/ means one bucket can serve many projects with clean per-project lifecycle rules.

Step-by-step explanation#

Checkout runs at the default fetch-depth: 1, so it's a shallow clone with no history. Combined with the .git/* exclusion below, what you get is a point-in-time snapshot rather than a repository. You can restore the code as it was. You cannot restore the project's history, branches, or tags.

For the "GitHub is gone" scenario that's a meaningful hole. You'd recover a working codebase and lose every commit message, every blame trail, every tag. If provider independence is genuinely the goal then a mirror clone preserves all of it and is barely more work, which I'll come back to.

Building the archive:

snippetbash
ZIP_NAME="${{project_name}}-$(date +'%Y%m%d-%H%M%S')-${GITHUB_SHA::7}.zip"
echo "ZIP_NAME=$ZIP_NAME" >> $GITHUB_ENV

${GITHUB_SHA::7} is bash substring expansion, first seven characters, matching git's conventional short SHA without spawning a subprocess.

Writing to $GITHUB_ENV is how you pass a value between steps. Plain export doesn't survive, because each run: block is a separate shell process, which is one of those things that's obvious in retrospect and confusing the first time. $GITHUB_OUTPUT with a step id is the more modern idiom and scopes better, but this is fine.

${{project_name}} is the same invalid Actions named-value that shows up across this library. github.event.repository.name removes the substitution step entirely.

Then the exclusions:

snippetbash
zip -r "$ZIP_NAME" . \
  --exclude "node_modules/*" ".git/*" ".env" ".env.dev" ".env.test" \
            "logs/*" "*.log" "coverage/*" ".nyc_output/*" ".cache/*"

The thinking here is right and I'd keep it in principle.

node_modules is regenerable from the lockfile and routinely ten to a hundred times the size of the source, so including it makes the archive expensive and slow without adding anything you could actually recover from. Same reasoning for coverage output, .nyc_output, .cache, and logs.

Excluding .env* is the one that matters for a different reason. Environment files hold database credentials, API keys, signing secrets. An archive containing them turns your backup bucket into a credential store, and backup buckets tend to be under-secured relative to their contents precisely because everyone thinks of them as holding "just code."

But that creates a tension the workflow doesn't resolve, and I want to name it rather than gloss over it: the archive is deliberately not sufficient to restore a running service. You get the code and no configuration. That's the correct security call and it means the restore procedure has a gap that has to be filled by a separate secrets path, Secrets Manager or SSM Parameter Store or 1Password. If that isn't written down somewhere, it becomes a very unpleasant discovery halfway through an incident.

One practical warning. zip's --exclude patterns match against paths as they're stored in the archive, and getting them subtly wrong (node_modules/* versus ./node_modules/*) fails silently. You get a bigger archive and no warning. The only way to know your exclusions work is to open the result and look, which is a specific instance of the general rule further down.

Upload:

snippetyaml
env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: aws s3 cp "$ZIP_NAME" "s3://${{ secrets.AWS_S3_BACKUP_BUCKET }}/backups/…"

Passing credentials through env: rather than interpolating them into the command body is exactly right. The AWS CLI reads them from the environment natively and the values never appear in the shell command text.

Two issues. These are long-lived IAM keys again, and they're a different set from the ECS workflow's: AWS_ACCESS_KEY_ID here versus AWS_IAM_ACCESS_KEY there. Two IAM identities, two rotation obligations, two things to forget about. Consolidating both onto OIDC role assumption gets rid of both.

And there's no permissions: block at all, so the job's GITHUB_TOKEN inherits the repository default, which on older repos is read/write across the board. A job that reads code and uploads a file wants contents: read.

Interesting implementation details#

Sortable keys are an incident response feature, which sounds grandiose for a date format but I stand by it. Lexicographic sorting means aws s3 ls gives you chronological order for free, and at 3am you want zero cognitive overhead between you and the right file.

The workflow is cheap by construction. No Docker, no dependency install, no build. Checkout, zip, upload, done in seconds. Which is exactly why the wrong cadence survived as long as it did. Cheap wrong things last much longer than expensive wrong things, because nothing ever forces you to look at them.

And the exclusion list is doing two completely different jobs in identical syntax. node_modules is about size. .env is about not creating a second credential store. Same line format, entirely different stakes, and nothing in the file distinguishes them. That deserves a comment, because the next person tidying up the list has no way to tell which entries are load-bearing.

Common mistakes#

Calling it a backup when it's a source snapshot. The dangerous part isn't the workflow, it's the belief it creates about what's covered.

Including .env files, which converts a backup bucket into a credential leak with a very long half-life.

Never testing a restore. I mean this seriously: if you've never restored from it, you don't know whether it works. You have a hypothesis. The number of teams that discover their backups were empty during the incident is not small, and it's not a beginner mistake either.

No lifecycle policy, so objects accumulate in Standard storage forever. A per-push cadence on an active repo produces thousands of near-identical multi-megabyte archives and the bill grows linearly until someone questions a line item.

Trusting silent exclusions.

Push cadence for a threat model that needs daily.

Long-lived IAM keys where OIDC works.

And no integrity verification anywhere. Nothing checks that the upload arrived intact or that the archive is even readable.

Lessons learned#

Name the threat before you build the mitigation. I built this before articulating what it defended against, and the wrong cadence is the direct result. Push-triggered was the instinct; it doesn't follow from the actual risk at all. Writing the threat model first would have produced schedule: on the first attempt and saved a lot of S3 objects.

"Backup" is an overloaded word and the ambiguity is genuinely dangerous. Source snapshot, database backup, disaster recovery, and archival retention are four different things with four different RPO and RTO profiles. Calling all of them "backup" is how a team ends up confident that the important one exists. This file should be called snapshot-source-to-s3.yml.

A backup you haven't restored from isn't a backup. The highest-value addition here is a scheduled job that pulls the newest archive, unzips it, installs, and builds, proving the thing is complete and usable. Everything short of that is faith.

And security decisions and optimisation decisions look identical in a config file. Without a comment, nobody can tell them apart, and the person cleaning up your exclusion list six months from now is going to make a judgement call with no information.

Production considerations#

A lifecycle policy isn't optional, it's the difference between this workflow costing nothing and costing something. Transition to Infrequent Access after 30 days, Glacier after 90, expire at whatever your retention period is. Without it the cost climbs monotonically and nobody notices until it's large enough to be a question in a meeting.

On bucket security: Block Public Access on, encryption enabled (S3 encrypts by default with SSE-S3 now, but SSE-KMS with a dedicated key gives you separate access control and an audit trail), versioning on, and a bucket policy denying deletes from the CI principal.

That last one matters more than it looks. The CI identity should be able to write new objects and never remove old ones, so a compromised pipeline credential can't destroy your history on its way out. Object Lock in compliance mode makes that guarantee cryptographic rather than policy-based, which is the standard defence against ransomware that specifically hunts for backups. Which it does.

IAM scope: s3:PutObject on arn:aws:s3:::bucket/backups/<project>/*. Not s3:*, not bucket-wide, and specifically not s3:DeleteObject.

Cross-account is the version of this that actually delivers on the premise. If provider independence is the goal, a backup sitting in the same AWS account as your production infrastructure shares a failure domain with it, and an account compromise takes both. A separate account with cross-account replication is the meaningful control.

And write the restore runbook. Which archive, how to fetch it, where the environment configuration comes from given it's deliberately absent, and what recovery time to expect. Undocumented restores take hours longer than they need to, and those hours land at the worst possible moment.

Improvements#

Rename it to snapshot-source-to-s3.yml so it stops implying something it doesn't do.

Move to a schedule:

snippetyaml
on:
  schedule:
    - cron: '0 3 * * *'
  workflow_dispatch:

Matches the threat model, cuts object count by an order of magnitude, and workflow_dispatch keeps the manual pre-migration snapshot available for when you want one.

Add the lifecycle policy. Best cost control available here by a distance.

Migrate to OIDC and consolidate with the ECS credentials. Add permissions: contents: read and timeout-minutes: 15.

Mirror the repository rather than the working tree:

snippetbash
git clone --mirror "https://github.com/${{ github.repository }}.git" repo.git
tar czf repo-mirror.tar.gz repo.git

Full history, branches, tags. That's what actually delivers provider independence rather than approximating it.

Verify the upload with aws s3api head-object, compare the size, fail on mismatch. Ten seconds of runtime for a real integrity signal instead of an assumed one.

And add a scheduled restore test. Monthly: fetch the newest archive, unzip, pnpm install --frozen-lockfile, pnpm build. If it fails, the backup was already broken and you found out on a Tuesday afternoon instead of during an outage. That's the improvement that moves this from faith to evidence, and it's the one I'd actually prioritise.

Then comment the security-critical exclusions so the .env lines never get mistaken for size optimisation.

And finally, deal with the real gap: back up the data. Database dumps and user uploads are the irreplaceable assets. This workflow doesn't touch either, and until something does, "we have backups" isn't a true statement about the system as a whole. It's a true statement about the least important part of it.

Next: notify.discord.yml. Twelve lines, more day-to-day value than anything else in the library, and a security bug I wrote without noticing.

Is zipping a git repository to S3 a useful backup?
Only against provider loss, not against data loss. Git already replicates your code, so the value is having a dated copy inside your own AWS account if a GitHub organisation is suspended, an owner account is compromised, or access disappears. It restores no database and no user uploads.
Should a source backup workflow run on every push?
No. Provider independence needs a recent copy, not one per commit, so twenty pushes a day produce twenty near-identical archives that mitigate nothing extra. A daily schedule with workflow_dispatch for manual pre-migration snapshots serves the same threat model at a fraction of the storage cost.
Why exclude .env files from a backup archive?
Environment files hold database credentials and API keys, and backup buckets are usually secured as though they contain "just code". Excluding them is correct, but it means the archive cannot restore a running service on its own, so the separate secrets path must be written into the restore runbook.
How do you stop a compromised CI credential from deleting your backups?
Scope the CI principal to s3:PutObject on a single prefix and deny s3:DeleteObject entirely, so it can write new objects and never remove old ones. S3 Object Lock in compliance mode makes that guarantee cryptographic, which is the standard defence against ransomware that targets backups.
How do you know a backup actually works?
Restore from it on a schedule. A monthly job that fetches the newest archive, unzips it, installs dependencies and builds proves the artifact is complete and usable. Until something does that, you have a hypothesis, and teams routinely discover empty backups during the incident itself.