Cheap CI/CD: What 'Free' Actually Means
This site runs on S3 and CloudFront specifically because “cheapest viable” was a hard requirement from day one, not a nice-to-have. So when it came time to add CI/CD (deploy automatically when a commit lands on main), the same constraint applied, except now to a category of tooling where “free tier” gets thrown around far more loosely than it should.
The first attempt, and why it got ripped out
The obvious move was GitHub Actions with OIDC federation to AWS: no long-lived credentials stored anywhere, workflow triggers natively on push, seemed like the textbook answer. The whole setup is a handful of lines of workflow:
on:
push:
branches: [main] # native trigger: no webhook, no server
permissions:
id-token: write # lets the job request an OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/ci
aws-region: eu-west-2
It took two separate real bugs to get
the AWS side working at all: the OIDC thumbprint value published in most setup guides was stale (verified the
real one directly via openssl against the actual certificate chain), and GitHub had since moved to an
immutable-ID format for the sub claim it sends (repo:org@id/repo@id:ref:... instead of the older
repo:org/repo:ref:... most trust-policy examples still assume).
The thumbprint check is one pipeline of openssl against the live endpoint:
openssl s_client -servername token.actions.githubusercontent.com \
-connect token.actions.githubusercontent.com:443 </dev/null 2>/dev/null \
| openssl x509 -fingerprint -sha1 -noout
And the trust policy ends up accepting both claim formats, roughly:
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": [
"repo:ORG/REPO:ref:refs/heads/main",
"repo:ORG@*/REPO@*:ref:refs/heads/main"
]
}
}
Both got fixed. The pipeline still didn’t trigger reliably in automatic mode afterward, and rather than keep debugging a trust relationship one layer removed from anything the site actually needed, it got removed entirely, replaced with a short, boring, manually-run deploy script. That’s not a failure of GitHub Actions; it’s what happens when you reach for the “correct” solution before confirming the boring one is actually the bottleneck.
Revisiting it properly meant checking real numbers, not memory
Coming back to the question later meant not repeating the first mistake: assuming a tool’s reputation instead of checking its current terms. A few things turned out to matter more than expected:
- GitHub Actions gives private repos 2,000 minutes a month, and that allowance doesn’t expire after a year; it’s the account’s standing free tier, not a trial.
- AWS CodeBuild’s 100 free build-minutes a month works the same way: ongoing, not a 12-month-only offer, which is easy to assume given how much of the rest of AWS’s free tier is time-limited.
- CircleCI and GitLab CI both have comparable always-free allowances for private repos: 30,000 credits and 400 minutes a month, respectively.
- Travis CI doesn’t belong on this list at all anymore: private-repo builds stopped being free back in 2021, and most “top free CI/CD tools” roundups still list it without checking.
None of that took more than a few minutes to verify. All of it would have been wrong to assume from memory.
The number that actually predicts the bill: does it need a server?
The more useful split wasn’t “which vendor,” it was a single question: does this option run on the trigger event and then stop, or does something have to sit there listening for the trigger 24/7?
A self-hosted pipeline tool (Concourse, Jenkins, anything you run yourself) is free as software. It is never free as a system, because “wait for a webhook” means a server has to be up whenever that webhook might arrive, which for a pipeline that fires a few times a month means paying for idle time almost all the time. Concourse’s own docs recommend 16GB of RAM for a single-node setup; even a much lighter tool like Jenkins wants a couple of GB just for the controller process. Either way, that’s a monthly bill that exists whether or not a deploy happens, and past the first 12 months of an AWS account’s life, EC2 compute isn’t part of the “Always Free” list, a detail that’s easy to miss because AWS does have a genuine always-free tier, just a much narrower one than “free tier” as a phrase implies.
Every managed option above inverts that: the trigger and the compute are the same event. Nothing runs, nothing costs anything, until a commit actually lands.
The credential question doesn’t go away, whichever you pick
Choosing a pay-per-use CI system answers “what does this cost,” not “what can it do to my AWS account.” That’s a separate decision, and it’s the one that actually burned time on this project the first time around. Whether it’s OIDC or a stored access key, what matters is the IAM principal’s scope: a purpose-built identity limited to exactly the resources the pipeline touches, never the same broad, everyday-use identity a human logs in with. OIDC’s advantage is not needing a stored secret at all; it is not a substitute for scoping the permissions on the other end of it correctly.
The general rule
For a pipeline this size, three questions predict the whole decision before comparing a single pricing page:
- Does the trigger already exist natively where the code lives, or would you have to build the “watch for a push” part yourself?
- Is the compute pay-per-use, or does something have to stay running whether or not a deploy is happening?
- Does the credential it uses have the smallest scope that could possibly work?
Anything that fails question two needs a genuinely good reason (“the software license says free” isn’t one) to be worth it for a project whose actual deploy frequency is a handful of times a month.
Decision made. Next up: what actually got built, what it costs in practice, and the security tradeoffs that turned out to matter more than the vendor pick.