CI/CD pipelines
Wire up automated deploys so every push to your main branch builds a fresh image and rolls it onto your bootload service — with a health check that keeps the old version serving if the new one is broken.
A pipeline on bootload is three moves:
- Once: deploy the service and mint a scoped CI token.
- Every push: build an image → push it to a registry →
bootload restart <service> --image <ref>. - Guardrail: the roll is health-gated. If the new image never passes its
health check, the old replicas keep serving — and you
bootload rollback.
restart --image is the whole trick. It starts a new deployment that rolls out
one replica at a time; the public route only switches to the new image once a
replica is healthy. A bad build doesn't take your site down, it just fails to
take over.
1. Deploy the service once
CI rolls an existing service — it never creates one. Create it once so its name, URL, and size are fixed:
bootload deploy --project my-project --name web \
--image ghcr.io/acme/web:bootstrap --port 8080:http
--port must match what your image actually listens on (nginx → 80:http, a
typical Node app → 3000:http). A public HTTPS URL is assigned automatically and
printed as a route line.
2. Mint a scoped CI token
Give CI its own key — never your personal login. These are the scopes a build-and-roll pipeline needs:
bootload token create ci-web \
--scope projects:read \
--scope services:read --scope services:write \
--scope deployments:read --scope deployments:write \
--scope registry:push --scope registry:pull \
--project <project-id> \
--resource service:<service-id>
The secret (blt_…) is printed once — store it as a CI secret named
BOOTLOAD_TOKEN. A few things worth knowing:
projects:readis required even though you're only touching a service — the CLI resolves the project first. Without it, every command fails withtoken lacks scope projects:read.--projectand--resource service:<id>shrink the blast radius: a leaked key can only touch what you named.registry:push/registry:pullare only needed if you use bootload's built-in registry (below). Drop them if you build and host images elsewhere.- Revoke it any time with
bootload token revoke <id>.
3. Keep a wallet balance
Deploys and rolls draw from the project's prepaid wallet. An empty wallet fails
the roll with wallet balance is zero. Keep a balance, or turn on auto-top-up
in the portal, before you rely on CI.
4. Choose a registry
bootload's built-in registry — recommended. For images you build, this is the fastest and most dependable source: pulls happen inside the datacenter (sub-millisecond, not an internet round-trip), they are never rate-limited the way anonymous Docker Hub pulls are, and no pull credentials are needed — your instances get a short-lived lease automatically. Storage is €0.03/GB-month.
bootload image login --org my-org --project my-project
bootload image push web:$TAG --org my-org --project my-project # → registry.bootload.io/my-org/my-project/web:$TAG
bootload restart web --project my-project --image registry.bootload.io/my-org/my-project/web:$TAG
Your own registry (GHCR, Docker Hub, ECR, …) — use this when the image already lives there for other consumers, or your organisation requires it. Build and push with its normal tooling, then point bootload at the reference; expect internet-latency pulls, and Docker Hub rate limits without credentials. For a private image, store pull credentials once so bootload can pull it at deploy time:
bootload registry add ghcr --project my-project \
--url ghcr.io --username <user> --token <read:packages token>
Public images need no credentials.
5. Deploy hooks: CI without an API key
An API key in CI can do everything your account can. A deploy hook is a signed URL that can do exactly one thing: roll one service onto another tag of the image it already runs. Leaking it is a bad day, not a catastrophe.
Create one per service:
bootload deploy-hook create web --name "github actions"
# prints the URL, and the secret once
Store both as CI secrets, then call it after your push:
curl -fsS -X POST "$BOOTLOAD_DEPLOY_HOOK?token=$BOOTLOAD_DEPLOY_SECRET&wait=1" \
-H 'content-type: application/json' -d '{"tag":"'"$GITHUB_SHA"'"}'
Send {"tag":"v2"} for a new tag, {"image":"<same repo>:v2"} for a full
reference, or no body at all to re-pull the configured image, which is what you
want for a mutable tag like :latest. An image outside the service's own
repository is refused with a 400. Unknown JSON fields are ignored, so you can
post a CI provider's own event payload as-is.
Signing is stronger than ?token= and works the same as wake webhooks:
X-Bootload-Signature: t=<unix>,v1=<hex hmac-sha256(secret, t + "." + body)>
?wait=1 returns the deployment that was created; without it you get a 202.
One call per 20 seconds per hook. Rotate with bootload deploy-hook rotate,
revoke by deleting it. The catch: a hook holds no read scope, so CI cannot poll
for health with it. Use an API key when the job must wait for the rollout.
6. GitHub Actions
The shortest version, with a deploy hook and no API key at all:
- uses: bootloadio/cicd/deploy@v1
with:
hook-url: ${{ secrets.BOOTLOAD_DEPLOY_HOOK }}
hook-secret: ${{ secrets.BOOTLOAD_DEPLOY_SECRET }}
tag: ${{ github.sha }}
With an API key, so the job can wait for the rollout to go healthy and roll back if it does not:
- uses: bootloadio/cicd/setup-bootload@v1
with:
token: ${{ secrets.BOOTLOAD_TOKEN }}
project: production
- uses: bootloadio/cicd/deploy@v1
with:
service: web
image: registry.bootload.io/acme/production/web:${{ github.sha }}
Both actions are thin wrappers around the CLI, kept in the platform repo so
they cannot drift from it. The full workflow below does the same thing without
them, which is worth reading once even if you use the actions: build and push
to GHCR with the built-in GITHUB_TOKEN, then roll bootload and wait for the
rollout to go healthy — rolling back if it doesn't. GitHub's ubuntu-latest
runner uses a plaintext Docker config, so registry logins just work.
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
concurrency: # never let two rolls race
group: deploy-web
cancel-in-progress: false
jobs:
ship:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/${{ github.repository }}/web
BOOTLOAD_TOKEN: ${{ secrets.BOOTLOAD_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Build and push
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
TAG=${{ github.sha }}
docker build -t "$IMAGE:$TAG" .
docker push "$IMAGE:$TAG"
echo "REF=$IMAGE:$TAG" >> "$GITHUB_ENV"
- name: Install the bootload CLI
run: curl -fsSL https://bootload.io/v1/cli/install.sh | sh
- name: Roll and verify
env:
PROJECT: acme-prod
SERVICE: web
run: |
bootload restart "$SERVICE" --project "$PROJECT" --image "$REF"
for i in $(seq 1 60); do
line=$(bootload deployments "$SERVICE" --project "$PROJECT" --plain | head -1)
case "$line" in
*succeeded*) echo "rolled out: $line"; exit 0 ;;
*failed*|*error*) echo "failed: $line"
bootload rollback "$SERVICE" --project "$PROJECT"; exit 1 ;;
esac
echo "… $line"; sleep 5
done
bootload rollback "$SERVICE" --project "$PROJECT"; exit 1
For a private repo image, run bootload registry add ghcr … once (step 4) so
bootload can pull it — the workflow itself doesn't change.
7. GitLab CI
This one uses bootload's built-in registry, so there's no second registry to manage:
# .gitlab-ci.yml
stages: [deploy]
deploy:
stage: deploy
image: docker:27
services: [docker:27-dind]
variables:
DOCKER_TLS_CERTDIR: "/certs"
ORG: acme
PROJECT: acme-prod
SERVICE: web
rules:
- if: $CI_COMMIT_BRANCH == "main"
resource_group: deploy-web
script:
- apk add --no-cache curl
- curl -fsSL https://bootload.io/v1/cli/install.sh | sh
- TAG=$CI_COMMIT_SHORT_SHA
- docker build -t web:$TAG .
- bootload image login --org "$ORG" --project "$PROJECT"
- bootload image push web:$TAG --org "$ORG" --project "$PROJECT"
- REF=registry.bootload.io/$ORG/$PROJECT/web:$TAG
- bootload restart "$SERVICE" --project "$PROJECT" --image "$REF"
- |
for i in $(seq 1 60); do
line=$(bootload deployments "$SERVICE" --project "$PROJECT" --plain | head -1)
case "$line" in
*succeeded*) exit 0 ;;
*failed*|*error*) bootload rollback "$SERVICE" --project "$PROJECT"; exit 1 ;;
esac
sleep 5
done
bootload rollback "$SERVICE" --project "$PROJECT"; exit 1
8. Verifying a rollout
bootload restart --image returns as soon as the deployment is accepted, not
when it's live — so a real pipeline must wait for the result:
bootload deployments <service> --plainlists deployments newest-first, one per line:<date> <id> <status> <image>.- The roll you just started is the top row. Poll it until
succeeded(pass) orfailed/error(fail), as the examples do. - States you'll see:
pending→rolling_out→succeeded, orfailed. - On failure,
bootload rollback <service>re-deploys the previous succeeded image;bootload rollback <service> --to <deployment-id>targets a specific one.
Skip this and a broken image silently never takes over while CI reports green.
Gotchas
- Always pass
--project(and--orgwhere a command takes it) in CI — there's no interactive "current project" to fall back on. - Use
--plainfor stable, parseable output. It's automatic when output isn't a terminal, so CI gets it for free. - If a custom runner has a Docker credential helper configured (
pass,osxkeychain) that isn't set up,bootload image loginfails witherror storing credentials. PointDOCKER_CONFIGat a directory whoseconfig.jsonhas nocredsStore. GitHub and GitLab default runners are fine. - Serialize rolls with
concurrency(GitHub) orresource_group(GitLab) so two pushes don't fight over the same service. - You can't delete an image a service still deploys (
HTTP 409) — roll to another tag first.
For AI coding agents
Both flows above ship as an installable agent skill, so a coding agent can set up your pipeline for you: bootload.io/skills/ci-cd-on-bootload/SKILL.md (and deploy-on-bootload for a first manual deploy).