CI/CD to Cloider with GitHub Actions
In this guide you build a complete continuous delivery pipeline with GitHub Actions: every pull request is tested and
its image is built, and every push to main is tested, built into a container image, pushed to the
Cloider Container Registry with a registry token, and rolled out to a
Cloider Kubernetes cluster with kubectl. A second, shorter workflow shows the alternative for
Cloider apps: one cloider deploy command with a CLOIDER_TOKEN.
At a glance
| You will build | A Node.js API api, built into registry.cloider.com/acme/api:<commit>, running as two pods in the shop namespace of the cluster production, at https://acme-api.cloider.app |
| Products | Container Registry, Kubernetes, Domains, and Apps for the alternative |
| Tools | GitHub Actions, the cloider CLI, kubectl, Docker. Optional: the GitHub CLI gh |
| Difficulty | Intermediate. You should know git, Docker and basic Kubernetes |
| Time | About 40 minutes |
| Cost | A registry project (storage), a cluster (compute), nothing for GitHub's own runners beyond your GitHub plan. See What it costs |
What you'll build
The pipeline uses three credentials, each with the least access it needs:
| Credential | Where it lives | What it can do |
|---|---|---|
Registry token github-actions (Pull & push, project acme, 1 year) | GitHub secrets CLOIDER_REGISTRY_USERNAME, CLOIDER_REGISTRY_PASSWORD | Push and pull images in acme, nothing else |
Registry token production-pull (Pull only) | Kubernetes Secret cloider-registry in the cluster | Pull images from acme |
| API token (Read) | GitHub secret CLOIDER_TOKEN | Read your Cloider account, including the cluster's kubeconfig. It can't create, change or delete anything in Cloider |
The kubeconfig itself is never stored in GitHub: the deploy job fetches it with the API token on every run.
Before you begin
- A Cloider account and the CLI, logged in with Read & Write access, see Install the CLI.
kubectland Docker on your computer, for the one-time setup.- A GitHub repository for the code, here
acme/api, and permission to add Actions secrets and environments to it. - Optional: the GitHub CLI (
gh), to set secrets from the terminal.
Part 1: Create the registry project
A registry project holds your images at registry.cloider.com/<project>/<repository>:<tag>. Project names are shared by
every Cloider account, so pick your own if acme is taken.
cloider registry create acme --wait
✓ Created project acme
✓ Project acme is ready
Push images to registry.cloider.com/acme/<repository>:<tag>, after `cloider registry login`.
See Create a project for the options (public pulls, storage limit, immutable tags).
Verify
cloider registry get acme
The project is Ready.
Part 2: Prepare the cluster
Create the cluster, connect kubectl, create the namespace, and give the namespace a pull secret so its pods can pull
private images from acme.
cloider cluster create production --wait
cloider cluster kubeconfig production --merge --use
kubectl create namespace shop
Create a pull-only registry token for the cluster. -q prints only the username and the secret, one per line, so
you can feed them straight into the Secret without copying:
{ read -r REG_USER; read -r REG_SECRET; } < <(
cloider registry token create --label production-pull --project acme --read-only -q
)
kubectl create secret docker-registry cloider-registry \
--docker-server=registry.cloider.com \
--docker-username="$REG_USER" \
--docker-password="$REG_SECRET" \
-n shop
secret/cloider-registry created
The -n shop matters: a Secret only works for pods in its own namespace. See
Pull private images.
Verify
kubectl get secret cloider-registry -n shop
cloider registry token list
NAME TYPE DATA AGE
cloider-registry kubernetes.io/dockerconfigjson 1 12s
USERNAME LABEL PROJECTS PERMISSION EXPIRES STATUS CREATED
acme production-pull acme Pull never Ready 2026-10-20 10:02
Part 3: Create the pipeline's credentials
A registry token for pushing
cloider registry token create --label github-actions --project acme --expires-days 365
✓ Created registry token github-actions
Username: acme
Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Host: registry.cloider.com
Projects: acme
Permission: Pull & Push
Expires: 2027-10-20 10:04
...
! The secret is not shown again, keep it now.
An API token for the deploy job
API tokens are created in the dashboard only, so a leaked token can't create more tokens:
- Open Access Control in the sidebar and choose the API Tokens tab.
- Click Generate API token.
- Description:
GitHub Actions acme/api. Permissions: Read. - Click Generate API token, copy the
cldr_...token, and click I've saved the token.
A Read token is enough here: downloading a kubeconfig is a read. Everything the deploy job then does inside the cluster is authorized by the kubeconfig, not by the API token. See API tokens.
Store them as GitHub secrets
In the repository on GitHub, open Settings, Secrets and variables, Actions, and add three repository secrets. With the GitHub CLI:
gh secret set CLOIDER_REGISTRY_USERNAME --repo acme/api --body "acme"
gh secret set CLOIDER_REGISTRY_PASSWORD --repo acme/api # paste the registry token's secret
gh secret set CLOIDER_TOKEN --repo acme/api # paste the cldr_... API token
Then create an environment called production (Settings, Environments, New environment). The deploy job
runs in it, so you can add required reviewers or restrict it to the main branch later.
Verify
gh secret list --repo acme/api
CLOIDER_REGISTRY_PASSWORD Updated 2026-10-20
CLOIDER_REGISTRY_USERNAME Updated 2026-10-20
CLOIDER_TOKEN Updated 2026-10-20
Part 4: Add the application and its manifests
The application is a tiny HTTP API with a health check and a test, so the pipeline has something real to test, build
and deploy. Any language works the same way: the pipeline only cares about the Dockerfile and the manifests.
api/
.dockerignore
.github/workflows/deploy.yml part 5
Dockerfile
k8s/api.yaml
package.json
package-lock.json created by npm install
app.js
server.js
test/app.test.js
{
"name": "api",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js",
"test": "node --test"
},
"engines": {
"node": "22.x"
}
}
// The request handler, kept apart from server.js so tests can start it on any port.
function handler(req, res) {
const json = (status, body) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
};
if (req.url === "/healthz") return json(200, { status: "ok" });
if (req.url === "/") return json(200, { service: "api", version: process.env.APP_VERSION || "dev" });
return json(404, { error: "not found" });
}
module.exports = { handler };
const http = require("node:http");
const { handler } = require("./app");
const port = Number(process.env.PORT) || 8080;
const server = http.createServer(handler);
server.listen(port, "0.0.0.0", () => console.log(`api listening on port ${port}`));
// Finish open requests when Kubernetes stops the pod during a rollout
process.on("SIGTERM", () => server.close(() => process.exit(0)));
const test = require("node:test");
const assert = require("node:assert");
const http = require("node:http");
const { handler } = require("../app");
test("health check answers ok", async () => {
const server = http.createServer(handler).listen(0);
const { port } = server.address();
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
assert.strictEqual(res.status, 200);
assert.deepStrictEqual(await res.json(), { status: "ok" });
} finally {
server.close();
}
});
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# The commit being built, shown by GET /
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION NODE_ENV=production
USER node
EXPOSE 8080
CMD ["node", "server.js"]
node_modules
.git
.github
test
The Kubernetes manifest uses the placeholder IMAGE, which the deploy job replaces with the exact image it just
pushed:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: shop
labels:
app: api
spec:
replicas: 2
selector:
matchLabels:
app: api
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: api
spec:
imagePullSecrets:
- name: cloider-registry
containers:
- name: api
image: IMAGE
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
name: api
namespace: shop
spec:
selector:
app: api
ports:
- name: http
port: 80
targetPort: http
maxUnavailable: 0 keeps both old pods serving until a new one is ready, and the readiness probe decides when that is,
so a broken image never takes the API down.
Create the lockfile and run the test once locally:
npm install
npm test
✔ health check answers ok (12.3ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0
Verify
npm test passes and docker build -t api-local . builds the image on your computer.
Part 5: Write the workflow
name: Test, build and deploy
on:
pull_request:
push:
branches: [main]
env:
REGISTRY: registry.cloider.com
IMAGE: registry.cloider.com/acme/api
CLUSTER: production
NAMESPACE: shop
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
build:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to the Cloider registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.CLOIDER_REGISTRY_USERNAME }}
password: ${{ secrets.CLOIDER_REGISTRY_PASSWORD }}
- name: Build, and push on main
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: ${{ github.event_name == 'push' }}
tags: ${{ env.IMAGE }}:sha-${{ github.sha }}
build-args: APP_VERSION=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
# One rollout at a time; a newer push waits for the running one
concurrency:
group: deploy-production
cancel-in-progress: false
env:
CLOIDER_TOKEN: ${{ secrets.CLOIDER_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v4
- name: Install the Cloider CLI
run: |
curl -fsSL https://cloider.com/install.sh | CLOIDER_INSTALL_DIR="$HOME/.local/bin" sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Get the kubeconfig
run: |
mkdir -p "$HOME/.kube"
cloider cluster kubeconfig "$CLUSTER" -f "$HOME/.kube/config"
kubectl version
- name: Roll out the new image
run: |
sed "s|image: IMAGE|image: $IMAGE:sha-$GITHUB_SHA|" k8s/api.yaml | kubectl apply -f -
if ! kubectl rollout status deployment/api -n "$NAMESPACE" --timeout=5m; then
echo "Rollout failed, rolling back"
kubectl rollout undo deployment/api -n "$NAMESPACE"
exit 1
fi
How it fits together:
- Pull requests run
testandbuildwithout logging in or pushing, so a pull request from a fork never sees your secrets. - Pushes to
mainpushregistry.cloider.com/acme/api:sha-<commit>. A unique tag per commit means every rollout is traceable, and it works even if you turn on immutable tags later. - The deploy job installs the CLI into
~/.local/bin, fetches the kubeconfig with the Read API token (the.kubefolder has to exist before-fwrites into it), and applies the manifest with the new image. If the new pods don't become ready within 5 minutes, it rolls back and fails the run. - Automatic CLI updates are off when the
CIvariable is set, which GitHub Actions does, so each run uses the CLI it installed. See Install the CLI.
Commit everything and push:
git add .
git commit -m "API with CI/CD"
git push origin main
Verify
On GitHub, open Actions: the run shows test, build and deploy in green. Then check the cluster and the
registry:
kubectl get pods -n shop
kubectl get deployment api -n shop -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
cloider registry images acme api
NAME READY STATUS RESTARTS AGE
api-7f9c6d5b8d-4qzkp 1/1 Running 0 41s
api-7f9c6d5b8d-v8m2n 1/1 Running 0 33s
registry.cloider.com/acme/api:sha-4f2a9c1e8b7d6a5f4e3d2c1b0a9f8e7d6c5b4a39
cloider registry images acme api lists the image with its sha-... tag.
Part 6: Put the API on the internet
Route a free subdomain to the api Service with a target. Cloider terminates HTTPS and forwards plain HTTP to port 80
of the Service:
cloider domain create acme-api.cloider.app
cloider target create acme-api.cloider.app --cluster production --service shop/api
✓ Claimed acme-api.cloider.app
Route it with `cloider target create acme-api.cloider.app --app <app>`.
✓ Routed acme-api.cloider.app/ to production/shop/api:80
Verify
curl -s https://acme-api.cloider.app/
{"service":"api","version":"4f2a9c1e8b7d6a5f4e3d2c1b0a9f8e7d6c5b4a39"}
Push another commit to main, and once the run is green the version changes to the new commit. To serve the API on
your own domain, see Expose services.
Alternative: deploy a Cloider app with cloider deploy
If you don't need Kubernetes, a Cloider app is simpler: Cloider builds the code (with your
Dockerfile, or a buildpack) and runs it, and the pipeline only has to run cloider deploy. You don't need a registry
project, registry tokens or a kubeconfig.
1. Create the app once, from your computer, in the repository folder. The first deploy creates the app and links the folder to it:
cloider deploy --name api --port 8080
Packaged 9 files (6.1 KB)
Detected JavaScript
✓ Created app api on port 8080
Linked . to api in .cloider/app.json, later deploys from here update it.
...
✓ Deployed version 1 of api
https://api-x7k2.cloider.app/
2. Create a Read & Write API token in Access Control, API Tokens, described GitHub Actions deploy, and
store it as the CLOIDER_TOKEN secret (replace the Read token from part 3 if you use the same repository).
3. Add the workflow:
name: Test and deploy the app
on:
push:
branches: [main]
concurrency:
group: deploy-app
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
environment: production
env:
CLOIDER_TOKEN: ${{ secrets.CLOIDER_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Install the Cloider CLI
run: |
curl -fsSL https://cloider.com/install.sh | CLOIDER_INSTALL_DIR="$HOME/.local/bin" sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Deploy
id: deploy
run: echo "url=$(cloider deploy --app api)" >> "$GITHUB_OUTPUT"
- name: Smoke test
run: curl -fsS "${{ steps.deploy.outputs.url }}healthz"
cloider deploy --app apizips the checkout (leaving out what.gitignoreand.cloiderignorelist), uploads it, streams the build log into the job log, and fails the step if the build fails or the app doesn't start. Build output goes to stderr and the URL to stdout, which is why$(...)captures only the URL.--app apideploys to the app by name, so the.cloider/app.jsonlink file doesn't need to be committed.- To run database migrations after a deploy, add a step such as
cloider app exec --timeout 15m api -- npm run migrate. See Terminal and exec. - An app builds one version at a time. The
concurrencygroup makes a second push wait instead of failing withThis app is already building, wait for that build to finish before deploying a new version!.
An app created from a GitHub repository with cloider app create --git github --repo acme/api deploys every push to
its branch by itself. Use a workflow with cloider deploy when you want tests or other checks to gate the deploy,
and don't mix the two on one app. See Deploy from git.
Verify
The run is green, the Deploy step's log ends with ✓ Deployed version 2 of api, and:
cloider app builds api
lists the new version as Ready.
What it costs
| Resource | How it is billed |
|---|---|
Registry project acme | Per GiB of image data stored per month. Every commit adds an image: keep it in check with a retention policy. Pushes and pulls are free |
Cluster production | Compute units for its running containers, including its control plane. The two api pods are billed for at least their 50m CPU and 64Mi memory requests |
App api (alternative) | Compute units for its one container while it runs. Builds are free |
| Domains, targets, registry tokens, API tokens | Free |
| GitHub Actions minutes | Billed by GitHub under your GitHub plan, not by Cloider |
See Pricing and the billing model.
Clean up
# The route and the subdomain
cloider domain delete acme-api.cloider.app --yes
# The workload, then the cluster
kubectl delete namespace shop
cloider cluster delete production --yes --wait
# The registry tokens and the project with all its images
cloider registry token delete github-actions --yes
cloider registry token delete production-pull --yes
cloider registry delete acme --yes --wait
# The app, if you followed the alternative
cloider app delete api --yes
Then delete the API token in Access Control, API Tokens, and the three secrets and the production environment
in the GitHub repository's settings (gh secret delete CLOIDER_TOKEN --repo acme/api, and so on).
Troubleshooting
| Error in the run | Cause and fix |
|---|---|
unauthorized at Log in to the Cloider registry | Wrong username or secret, or the token expired or was deleted. The username is your registry username (acme here), shown with the token |
denied: requested access to the resource is denied on push | The token is Pull only, or doesn't cover the project in the image name |
not logged in or exit code 3 at Get the kubeconfig | CLOIDER_TOKEN is missing, mistyped or deleted. Check the secret's name and generate a new token |
open /home/runner/.kube/config: no such file or directory | The .kube folder doesn't exist. Keep the mkdir -p "$HOME/.kube" line |
cloider: command not found | ~/.local/bin isn't on the PATH of later steps. Keep the echo ... >> "$GITHUB_PATH" line |
Pods stay in ImagePullBackOff | The cloider-registry Secret is missing from the shop namespace, or its token was deleted. Run kubectl describe pod -n shop and recreate the Secret with -n shop |
error: deployment "api" exceeded its progress deadline | The new pods never became ready. kubectl logs -n shop deploy/api shows why. The job rolled back to the previous version |
exit code 5 or This API token is read only! in the app workflow | cloider deploy needs a Read & Write token |
This app is already building... | Two deploys overlapped. Keep the concurrency group |
More in Container registry troubleshooting, Kubernetes troubleshooting and Output and scripting.
Next steps
- CI/CD with GitLab: the same pipeline as a
.gitlab-ci.yml. - Retention policies: delete old
sha-*images on a schedule. - Infrastructure as code with Terraform: create the cluster, the project and the tokens with Terraform, and write the GitHub secrets from it.
- Registry CI/CD reference: multi-platform builds, other CI systems.
Frequently asked questions
How do I push to the Cloider registry from GitHub Actions?
Create a registry token with Pull & push permission for the project, store its username and secret as repository secrets, log in with docker/login-action to registry.cloider.com, and push with docker/build-push-action to registry.cloider.com/<project>/<repository>:<tag>.
How does GitHub Actions get the kubeconfig of a Cloider cluster?
Install the cloider CLI in the job, set CLOIDER_TOKEN from a secret, create the ~/.kube folder and run cloider cluster kubeconfig <cluster> -f ~/.kube/config. A Read API token is enough, because downloading a kubeconfig is a read.
Should I store the kubeconfig as a GitHub secret?
You can, but fetching it with a Read API token keeps one fewer long-lived admin credential in GitHub. The kubeconfig grants full admin access to the cluster and stays valid for the cluster's lifetime.
How do I deploy a Cloider app from GitHub Actions?
Install the CLI, set CLOIDER_TOKEN to a Read & Write API token, and run cloider deploy --app <app>. It uploads the checkout, streams the build log, fails the job if the build fails, and prints the app's URL on stdout.
How do I roll back a bad deploy?
For Kubernetes, kubectl rollout undo deployment/<name> -n <namespace> returns to the previous ReplicaSet; the workflow in this guide does it automatically when the rollout fails. For an app, push a revert commit or redeploy an earlier commit.
