Skip to main content

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

  1. Log in to Portainer
  2. Go to My Account > Access tokens > Add access token
  3. Give it a descriptive name (e.g. github-actions-deploy)
  4. 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

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:

SecretHow to get it
PORTAINER_URLYour Portainer instance URL (e.g. https://portainer.example.com)
PORTAINER_TOKENPortainer access token from Step 1
SECRET_KEY_BASECopy from Canine's environment variables
DATABASE_PASSWORDCopy 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.

Step 4: Disable Canine and Verify

  1. Disable autodeploy in Canine for the project so both systems aren't deploying
  2. Push a commit to main and confirm the pipeline deploys successfully
  3. 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.