# Publish container images

## Prerequisites

- A Codebahn account (both Solo and Crew include hosted CI)
- A repository with a `Dockerfile`
- Familiarity with Docker builds

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](#push-from-outside-codebahn-ci). For the registry reference (image names, auth scopes, quota), see [Container registry](/docs/container-registry/).

## 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.

```yaml
name: release
on:
  push:
    tags:
      - "v*"

permissions:
  contents: read

jobs:
  image:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - 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](https://forgejo.org/docs/latest/user/actions/#publishing-releases).

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).

:::caution
A pull request from a fork runs untrusted code, so the built-in token gets no push or private-pull access on a fork PR. Those builds can still pull public upstream base images; they cannot push to your registry or read your private images. Publish on tags or your protected branches, not on fork pull requests.
:::

## 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.

1. Register a new user, for example `acme-ci`.
2. Add it to the org with **Write** on the repositories it publishes for.
3. Log in as that user and create a [personal access token](https://forgejo.org/docs/latest/user/api-usage/#authentication) scoped to **`write:package`** only. Nothing else.

```bash
echo "$REGISTRY_TOKEN" | docker login codebahn.net -u acme-ci --password-stdin
image="codebahn.net/acme/api"
docker build -t "$image:1.4.0" .
docker push "$image:1.4.0"
docker logout codebahn.net
```

To 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

- **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:package` only. 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: read` at the top.

## 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.

```bash
image="codebahn.net/acme/api"
docker build -t "$image:${{ github.sha }}" -t "$image:latest" .
docker push "$image:${{ github.sha }}"
docker push "$image:latest"
```
