Ejecting with Portainer
If your cluster is managed through Portainer, you can use a Portainer API token instead of a raw kubeconfig when ejecting from Canine. The pipeline fetches a kubeconfig from Portainer's API at deploy time — no static kubeconfig to rotate.
This guide assumes you've already completed Steps 1 and 2 from the Ejecting from Canine guide (downloading manifests and adding them to your repo).
Step 1: Create a Portainer Access Token
- Log in to Portainer
- Go to My Account > Access tokens > Add access token
- Give it a descriptive name (e.g.
github-actions-deploy) - Copy the token — you'll store it as a CI secret
Step 2: Find your Endpoint ID
Your cluster's endpoint ID is visible in the Portainer URL when viewing the cluster:
https://portainer.example.com/#!/2/kubernetes/dashboard
^
endpoint ID is 2
Step 3: Add the CI/CD Pipeline
- GitHub Actions
- GitLab CI/CD
Create .github/workflows/deploy.yml:
name: Build and Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
NAMESPACE: my-namespace # your project's namespace
PORTAINER_ENDPOINT_ID: "2" # your cluster's endpoint ID in Portainer
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest
- id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v4
- name: Fetch kubeconfig from Portainer
run: |
mkdir -p $HOME/.kube
curl -sk \
-H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \
"${{ secrets.PORTAINER_URL }}/api/kubernetes/config" \
> /tmp/full-kubeconfig.json
# Filter to just your cluster's endpoint
python3 -c "
import json, yaml
with open('/tmp/full-kubeconfig.json') as f:
kc = json.load(f)
endpoint = '${{ env.PORTAINER_ENDPOINT_ID }}'
kc['clusters'] = [c for c in kc['clusters'] if c['cluster']['server'].endswith(f'/api/endpoints/{endpoint}/kubernetes')]
cluster_name = kc['clusters'][0]['name']
kc['contexts'] = [c for c in kc['contexts'] if c['context']['cluster'] == cluster_name]
kc['current-context'] = kc['contexts'][0]['name']
print(yaml.dump(kc))
" > $HOME/.kube/config
- name: Create secrets
run: |
kubectl create secret generic ${{ env.NAMESPACE }} \
--namespace=${{ env.NAMESPACE }} \
--from-literal=SECRET_KEY_BASE=${{ secrets.SECRET_KEY_BASE }} \
--from-literal=DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} \
--dry-run=client -o yaml | kubectl apply -f -
- name: Apply manifests
run: kubectl apply -f k8s/
- name: Update image
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}"
kubectl set image deployment --all *=$IMAGE --namespace=${{ env.NAMESPACE }}
kubectl set image cronjob --all *=$IMAGE --namespace=${{ env.NAMESPACE }} 2>/dev/null || true
- name: Wait for rollout
run: |
for deploy in $(kubectl get deploy -n ${{ env.NAMESPACE }} -o name); do
kubectl rollout status $deploy -n ${{ env.NAMESPACE }} --timeout=300s
done
Required GitHub Secrets
Add these under your repo's Settings > Secrets and variables > Actions:
| Secret | How to get it |
|---|---|
PORTAINER_URL | Your Portainer instance URL (e.g. https://portainer.example.com) |
PORTAINER_TOKEN | Portainer access token from Step 1 |
SECRET_KEY_BASE | Copy from Canine's environment variables |
DATABASE_PASSWORD | Copy from Canine's environment variables |
Add one --from-literal line per secret env var in the "Create secrets" step. GITHUB_TOKEN is provided automatically for ghcr.io.
Create .gitlab-ci.yml in the root of your repository:
stages:
- build
- deploy
variables:
REGISTRY: registry.gitlab.com
IMAGE_NAME: $CI_PROJECT_PATH
NAMESPACE: my-namespace # your project's namespace
PORTAINER_ENDPOINT_ID: "2" # your cluster's endpoint ID in Portainer
build:
stage: build
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build -t $CI_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA -t $CI_REGISTRY/$IMAGE_NAME:latest .
- docker push $CI_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA
- docker push $CI_REGISTRY/$IMAGE_NAME:latest
only:
- main
deploy:
stage: deploy
image: bitnami/kubectl:latest
before_script:
- apk add --no-cache curl python3 py3-yaml
- mkdir -p $HOME/.kube
- |
curl -sk \
-H "X-API-Key: $PORTAINER_TOKEN" \
"$PORTAINER_URL/api/kubernetes/config" \
> /tmp/full-kubeconfig.json
python3 -c "
import json, yaml
with open('/tmp/full-kubeconfig.json') as f:
kc = json.load(f)
endpoint = '$PORTAINER_ENDPOINT_ID'
kc['clusters'] = [c for c in kc['clusters'] if c['cluster']['server'].endswith(f'/api/endpoints/{endpoint}/kubernetes')]
cluster_name = kc['clusters'][0]['name']
kc['contexts'] = [c for c in kc['contexts'] if c['context']['cluster'] == cluster_name]
kc['current-context'] = kc['contexts'][0]['name']
print(yaml.dump(kc))
" > $HOME/.kube/config
script:
# Create secrets
- |
kubectl create secret generic $NAMESPACE \
--namespace=$NAMESPACE \
--from-literal=SECRET_KEY_BASE=$SECRET_KEY_BASE \
--from-literal=DATABASE_PASSWORD=$DATABASE_PASSWORD \
--dry-run=client -o yaml | kubectl apply -f -
# Apply manifests
- kubectl apply -f k8s/
# Update image on all deployments and cronjobs
- IMAGE="$CI_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA"
- kubectl set image deployment --all *=$IMAGE --namespace=$NAMESPACE
- kubectl set image cronjob --all *=$IMAGE --namespace=$NAMESPACE 2>/dev/null || true
# Wait for rollout
- |
for deploy in $(kubectl get deploy -n $NAMESPACE -o name); do
kubectl rollout status $deploy -n $NAMESPACE --timeout=300s
done
only:
- main
Required GitLab CI/CD Variables
Add these under your project's Settings > CI/CD > Variables:
| Variable | Type | How to get it |
|---|---|---|
PORTAINER_URL | Variable (masked) | Your Portainer instance URL (e.g. https://portainer.example.com) |
PORTAINER_TOKEN | Variable (masked) | Portainer access token from Step 1 |
SECRET_KEY_BASE | Variable (masked) | Copy from Canine's environment variables |
DATABASE_PASSWORD | Variable (masked) | Copy from Canine's environment variables |
Add one --from-literal line per secret env var in the script. CI_REGISTRY_USER and CI_REGISTRY_PASSWORD are provided automatically for the GitLab Container Registry.
Step 4: Disable Canine and Verify
- Disable autodeploy in Canine for the project so both systems aren't deploying
- Push a commit to
mainand confirm the pipeline deploys successfully - Once confident, remove the project from Canine
Removing a project from Canine does not delete your running workloads — your app stays up.
How it works
Portainer's /api/kubernetes/config endpoint returns a kubeconfig where the server URL points to Portainer's kubectl proxy:
https://portainer.example.com/api/endpoints/2/kubernetes
All kubectl commands route through this proxy. Portainer enforces access control and provides audit logging. The rest of the pipeline (apply manifests, update image, rollout) works exactly the same as a direct kubeconfig approach — the only difference is where the kubeconfig comes from.