A principle: You should be able to maintain your production system when your CI vendor is down.
This doesn’t mean you must maintain reduntant CI systems, but rather define your build with an open toolkit and only use the CI vendor-specific definition languages as “glue”.
Reading the CircleCI guide for the Go language, the best practice for building and deploying a Go project is to combine a series of CircleCI “orbs” with their only-works-on-circleci YAML language, producing something like this:
version: 2.1
orbs:
go: circleci/go@1.7.3
heroku: circleci/heroku@1.2.6
jobs:
build_and_test:
executor:
name: go/default
tag: '1.19.2'
steps:
- checkout
- go/load-cache
- go/mod-download
- go/save-cache
- go/test:
covermode: atomic
failfast: true
race: true
- persist_to_workspace:
root: ~/project
paths: .
deploy:
executor: heroku/default
steps:
- attach_workspace:
at: ~/project
- heroku/deploy-via-git:
force: true
workflows:
test_my_app:
jobs:
- build_and_test
- deploy:
requires:
- build_and_test
filters:
branches:
only: main
Github Actions and the rest of them are similar for obvious reasons: Once you go down this route, you’ve created an expensive wall of work you’d need to climb to switch CI provider. Locking you into their closed build spec langauges allows these vendors to charge higher prices.
Yes it’s possible to run these locally - but have you tried? It’s not pleasant. Have you tried when their servers are down?
Instead, what you should do is to use open build tools - Make, Bazel, Just, Pants, clean bash scripts or whatever you prefer - to define how your system is tested and deployed. Then you use the CI vendors build language to call your actual build script: A thin veneer that tells the CI tool how and when to run what parts of your build and how to visualize the DAG.
Here’s an example alternative version of the “best practice” way to do it from above:
version: 2.1
jobs:
build_and_test:
docker:
# Or any other image you like; devenv is nice tho because then getting CI and local setups to be identical is trivial
image: ghcr.io/cachix/devenv/devenv:<pick a version or hash>
steps:
- checkout
# If you use devenv, then this will install all dependencies you've configured and then run make.
# The exact same command will do the same thing on developer machine.
# Skip devenv if you don't care for it, and use Just, Bazel, Pants or any other tool you prefer over Make
- run: devenv shell make build test
- persist_to_workspace:
root: ~/project
paths: .
deploy:
docker:
image: cimg/base:2025.11
steps:
- attach_workspace:
at: ~/project
- run: devenv shell make deploy
workflows:
# We can still define build CI DAGs if we like looking at them and setting up gates between steps:
test_my_app:
jobs:
- build_and_test
- deploy:
requires:
- build_and_test
filters:
branches:
only: main
There are multiple benefits to this:
- You can maintain your systems even when your CI vendor is down
- You learn open tools that work everywhere, rather than vendor-specific languages that only exist to extract rent
- You can work on and debug your CI pipeline locally with much better devex than the “local” options offered by the CI vendors
- You won’t have a separate partially-broken “run tests locally” flow; CI and local are the same build, less stuff to maintain
And: You still maintain the ability of your CI system to visualize and control the build automation for you.
A note on secrets
Obviously you may not want 1000 engineers with keys to deploy to production. Instead, again, use something that works on both your CI system and on local machines, combined with some break-glass procedure to access normally-locked-away keys in an emergency.
Basically any secrets manager that isn’t the one provided by the CI PaaS vendors will do this - whether you use SecretSpec, pass, 1password CLI or AWS Secrets Manager or any of the others.