# Deploy to a PaaS

PaaS providers have built-in GitHub integrations but no native Codebahn connection. You deploy by running the provider's CLI in your [CI workflow](/docs/set-up-ci/), the same way you would run tests. To get deploy status back on your commits and pull requests, you create a Codebahn environment and point the provider's webhook at its callback URL.

## Prerequisites

- A working CI workflow ([Set up CI](/docs/set-up-ci/))
- An account with your chosen PaaS provider
- Crew plan for hosted runners (or a [BYO runner](/docs/run-your-own-ci/))

The deploy steps below are ordinary CI. The Codebahn-specific part is the environment callback: when the provider POSTs a status to the callback URL you configured, Codebahn writes a commit status and, for previews, a PR comment. See [environments](/docs/environments/) for the callback mechanism, payload formats, and security model.

## How it works

1. Add an environment in the repository's **Environments** tab (name + provider). Codebahn gives you a callback URL with a secret token.
2. Add the provider's deploy CLI command to your CI workflow.
3. In the provider's settings, add a webhook pointed at the callback URL.
4. On push, CI runs your tests and deploys via the CLI. When the provider POSTs to the callback URL, Codebahn writes the commit status.

The environment callback is optional. If you skip it, the commit status comes from the CI step's exit code instead, which is enough when the deploy runs entirely inside your workflow.

## Per-provider setup

The workflow snippets are generic CI. Add provider secrets under **Settings > Actions > Secrets** (stock Forgejo; see the [upstream docs](https://forgejo.org/docs/latest/user/actions/#secrets)). Jobs run on hosted EU runners, so any CLI you reference is installed in the step, not pre-baked into the image. See [CI runners](/docs/ci-runners/) for what's already on the image.

### [Vercel](https://vercel.com)

**Secrets:**
- `VERCEL_TOKEN` from [vercel.com/account/tokens](https://vercel.com/account/tokens)
- `VERCEL_ORG_ID` and `VERCEL_PROJECT_ID` from `.vercel/project.json` (run `vercel link` locally)

**Workflow:**
```yaml
deploy-production:
  runs-on: ubuntu-latest
  needs: check
  if: github.ref == 'refs/heads/main'
  env:
    VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
    VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
  steps:
    - uses: actions/checkout@v7
    - run: npm i -g vercel
    - run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}

deploy-preview:
  runs-on: ubuntu-latest
  needs: check
  if: github.event_name == 'pull_request'
  env:
    VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
    VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
  steps:
    - uses: actions/checkout@v7
    - run: npm i -g vercel
    - name: Deploy preview
      id: deploy
      run: |
        url=$(vercel deploy --token=${{ secrets.VERCEL_TOKEN }})
        echo "url=$url" >> "$GITHUB_OUTPUT"
    - name: Comment preview URL
      run: |
        curl -s -X POST \
          -H "Authorization: token ${{ secrets.CODEBAHN_TOKEN }}" \
          -H "Content-Type: application/json" \
          -d "{\"body\":\"Preview: ${{ steps.deploy.outputs.url }}\"}" \
          "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments"
```

The preview job posts the deploy URL as a PR comment via the API. `CODEBAHN_TOKEN` is a personal access token you create and add as an Actions secret yourself; it is not injected for you. If you set up the Vercel callback below, the environment posts its own preview comment and you can drop this step.

**Callback:** Create a webhook in [Vercel > Project Settings > Webhooks](https://vercel.com/docs/webhooks) pointed at the callback URL from your environment. Select `deployment.succeeded`, `deployment.error`, `deployment.created`, `deployment.canceled`. Vercel signs callbacks; paste the signing secret into the environment.

### [Netlify](https://www.netlify.com)

**Secrets:**
- `NETLIFY_AUTH_TOKEN` from [app.netlify.com](https://app.netlify.com/user/applications#personal-access-tokens)
- `NETLIFY_SITE_ID` from Site configuration > General

**Workflow** (build in CI, upload output):
```yaml
deploy:
  runs-on: ubuntu-latest
  needs: check
  if: github.ref == 'refs/heads/main'
  steps:
    - uses: actions/checkout@v7
    - run: pnpm install && pnpm build
    - run: npx netlify-cli deploy --dir=./dist --prod --auth=${{ secrets.NETLIFY_AUTH_TOKEN }} --site=${{ secrets.NETLIFY_SITE_ID }}
```

**Callback:** In [Site configuration > Notifications > Outgoing webhooks](https://docs.netlify.com/deploy/deploy-notifications/), add "Deploy succeeded" and "Deploy failed" pointed at the callback URL.

### [Railway](https://railway.com)

**Secrets:**
- `RAILWAY_TOKEN` from project settings (project-scoped token)

**Workflow:**
```yaml
deploy:
  runs-on: ubuntu-latest
  needs: check
  if: github.ref == 'refs/heads/main'
  steps:
    - uses: actions/checkout@v7
    - run: npm i -g @railway/cli
    - run: railway up --detach
  env:
    RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
```

**Callback:** In [Project Settings > Webhooks](https://docs.railway.com/guides/webhooks), add the callback URL and select deployment events. Railway does not sign callbacks; the secret token in the callback URL is the authentication.

### [Cloudflare Pages](https://pages.cloudflare.com)

**Secrets:**
- `CLOUDFLARE_API_TOKEN` with "Cloudflare Pages: Edit" permission
- `CLOUDFLARE_ACCOUNT_ID` from the Cloudflare dashboard

**Workflow** (build in CI, upload output):
```yaml
deploy:
  runs-on: ubuntu-latest
  needs: check
  if: github.ref == 'refs/heads/main'
  steps:
    - uses: actions/checkout@v7
    - run: pnpm install && pnpm build
    - run: npx wrangler pages deploy ./dist --project-name=my-project
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
```

Cloudflare Pages can send deploy alerts as a dashboard notification (see [environments](/docs/environments/)). If you skip that, the commit status comes from the CI step's exit code.

### [Render](https://render.com)

Render does not deploy from source without a connected git repo. Two options:

**Option A: Docker image.** Build and push an image to your Codebahn registry in CI, then trigger Render. The built-in token pushes to your own owner with no secret to manage; see [Publish container images](/docs/publish-container-images/).
```yaml
deploy:
  runs-on: ubuntu-latest
  needs: check
  if: github.ref == 'refs/heads/main'
  steps:
    - uses: actions/checkout@v7
    - run: echo "${{ github.token }}" | docker login codebahn.net -u "${{ github.actor }}" --password-stdin
    - run: docker build -t codebahn.net/${{ github.repository }}:${{ github.sha }} .
    - run: docker push codebahn.net/${{ github.repository }}:${{ github.sha }}
    - run: |
        curl -X POST "https://api.render.com/v1/services/${{ secrets.RENDER_SERVICE_ID }}/deploys" \
          -H "Authorization: Bearer ${{ secrets.RENDER_API_KEY }}" \
          -H "Content-Type: application/json" \
          -d '{"imageUrl":"codebahn.net/${{ github.repository }}:${{ github.sha }}"}'
```

Images are private, so give Render a `read:package` token as a registry credential to pull. See [pull from outside Codebahn](/docs/consume-container-images/). Keeping the image on Codebahn rather than a US registry holds the [no-US-vendor](/docs/subprocessors/) line.

**Callback:** In [Settings > Webhooks](https://render.com/docs/webhooks) (a paid Render plan is required; check your plan), add the callback URL.

## Verify

Push a commit. Open the repository's **Environments** tab and watch for callback events as the provider processes the deploy, then check the commit for its status.
