Publish container images
This guide builds an image in CI and pushes it to your registry on a tag. A job running in Codebahn CI authenticates with the built-in workflow token, so for the common case there is no credential to create or store. To push from outside Codebahn CI, or into an owner the job does not belong to, use a machine-user token. For the registry reference (image names, auth scopes, quota), see Container registry.
Push with the built-in token
Section titled “Push with the built-in token”A job can push to its own owner’s registry with the built-in ${{ github.token }}. No machine user, no personal access token, no Actions secret. The token carries the access of the job’s owner (your user or org) and is valid only while the job runs.
name: releaseon: push: tags: - "v*"
permissions: contents: read
jobs: image: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Log in to the registry run: echo "${{ github.token }}" | docker login codebahn.net -u "${{ github.actor }}" --password-stdin
- name: Build and push run: | image="codebahn.net/${{ github.repository }}" docker build -t "$image:${{ github.ref_name }}" . docker push "$image:${{ github.ref_name }}"Tagging v1.4.0 and pushing it builds and publishes codebahn.net/acme/api:1.4.0. Pair it with a release step to ship notes and the image together; see the Forgejo release action.
The built-in token pushes only to the registry of the owner that runs the job. It cannot push to another user or org; for that, use a machine-user token.
Push from outside Codebahn CI
Section titled “Push from outside Codebahn CI”To push from a workstation, an external CI system, or into an org the job does not belong to, authenticate with a token instead of the built-in one. Use a dedicated machine user, not a personal account: it is revocable on its own, is not tied to a person who might leave, and limits the blast radius if the token leaks.
- Register a new user, for example
acme-ci. - Add it to the org with Write on the repositories it publishes for.
- Sign in as that user and create a personal access token scoped to
write:packageonly. Nothing else.
echo "$REGISTRY_TOKEN" | docker login codebahn.net -u acme-ci --password-stdinimage="codebahn.net/acme/api"docker build -t "$image:1.4.0" .docker push "$image:1.4.0"docker logout codebahn.netTo run this from a Codebahn workflow that publishes into another org, store the token as an Actions secret and the username as a variable, then log in with ${{ secrets.REGISTRY_TOKEN }} over stdin. Keep that job on tags or protected branches; a fork pull request never receives secrets.
Hardening checklist
Section titled “Hardening checklist”- Prefer the built-in token. It needs no stored secret and expires with the job. Reach for a machine-user token only when you push from outside Codebahn CI or across owners.
- Least-privilege token. When you do use one, scope it to
write:packageonly. Not a personal token, not an admin. - Log in over stdin. Always
--password-stdin. Never put a token on the command line, where it lands in logs and the process list. - Pin third-party actions by commit SHA.
uses: actions/checkout@<sha>instead of a moving tag, so a retag upstream cannot change what runs. - Trigger on tags or protected branches. Not on fork pull requests, which get neither secrets nor registry write.
- Drop unused permissions.
permissions: contents: readat the top.
Tag immutably
Section titled “Tag immutably”Push an immutable, versioned tag (:1.4.0, or the commit SHA) for anything you deploy, so a rollback always finds the exact image. Use :latest only as a convenience pointer, never as the thing a deploy pins to.
image="codebahn.net/acme/api"docker build -t "$image:${{ github.sha }}" -t "$image:latest" .docker push "$image:${{ github.sha }}"docker push "$image:latest"