Skip to content
CodeFloe

Preview Environments

Preview environments give every pull request its own short-lived deployment, so reviewers can open the proposed change in a browser instead of building it locally. They are provided by roost, deployed when a pull request opens and torn down again when it closes or merges.

A preview is either a directory of files or a running container:

  • Static uploads a built directory, which the edge serves as-is. This is the simplest path and needs nothing but a build step that emits a folder.
  • Container runs your application and reverse-proxies to it, for anything that has to actually execute: a server-rendered app, an API, a backend the frontend talks to.

A container preview gets its image in one of two ways:

  • From a registry, by reference (--image / image), which means CI has to build the image and push it somewhere first.
  • From an uploaded archive (--image-archive / image_archive), where CI builds the image to a .tar file and hands that file to roost directly.

Prefer the archive on CodeFloe. A preview image is built once, serves a single pull request, and is then thrown away, so pushing it to the container registry spends package quota on an artifact nobody keeps. Uploading the archive skips the registry entirely, which also removes the registry login and its credentials from the pipeline.

Each preview is served at its own subdomain of preview.codefloe.com:

https://<owner>-<repo>-pr-<number>-<hash>.preview.codefloe.com

For example, pull request 27 in codefloe/docs is served at:

https://codefloe-docs-pr-27-053ebd.preview.codefloe.com

The parts are:

  • <owner>-<repo> identifies the repository.
  • pr-<number> is the pull request number.
  • <hash> is a short hash of the repository, so two repositories that would otherwise slugify to the same name never collide on a shared daemon.

The name is deterministic: the same pull request always maps to the same URL, so redeploys reuse it and the teardown removes the right one.

CodeFloe runs one shared roost daemon, so there is nothing to install or operate. Its control API lives at:

http://100.64.0.16:7420

The address is internal: CodeFloe’s CI runners can reach it, the public internet cannot. It is also not a credential, because every deploy is authenticated separately, so it does not need to be kept secret.

The roost client reads the daemon URL from ROOST_SERVER and falls back to http://127.0.0.1:7420 when that variable is empty. A job that never sets it therefore fails with:

error: Post "http://127.0.0.1:7420/v1/previews": dial tcp 127.0.0.1:7420: connect: connection refused

This is the signature of an unset or misspelled ROOST_SERVER, not of a daemon outage.

  • On a pull request the preview is deployed (or updated on every new push).
  • On close or merge the preview is destroyed.

When the preview is deployed, a bot comment posts the link on the pull request and keeps it up to date. On teardown the comment is struck through and suffixed with “→ torn down”, leaving a record that a preview existed.

Previews work on both CI systems CodeFloe offers. Full, working examples for each (static site and container) live in codefloe/roost-preview-examples.

The two examples below deploy a static site. For an app that has to run, keep the same wiring and swap the payload as described under container previews.

Add a roost step to your Crow CI pipeline that runs on the pull request lifecycle events. The roost_server and roost_token secrets already exist as global secrets in Crow CI, so you do not need to create them yourself: just reference them with from_secret.

when:
  - event: [pull_request, pull_request_closed, pull_request_merged]

steps:
  build:
    image: node:lts-alpine
    commands:
      - npm ci
      # emits ./dist
      - npm run build
    when:
      - event: pull_request

  preview:
    image: codefloe.com/crow-plugins/roost:<version>
    settings:
      server:
        from_secret: roost_server
      token:
        from_secret: roost_token
      dir: ./dist
      ttl: 168h
    when:
      - event: [pull_request, pull_request_closed, pull_request_merged]

The single step covers the whole lifecycle: it deploys on pull_request and destroys on pull_request_closed and pull_request_merged. The ttl is a safety net so the daemon reclaims a preview even if the teardown event never fires.

Forgejo Actions has no dedicated roost action, so the jobs run the roost CLI directly. They authenticate without a shared secret: each job mints a short-lived OIDC token (enable-openid-connect: true) and exchanges it for the roost bearer token. roost verifies the token and scopes the caller to its own repository, so deploys are named from the repository and pull request (--repo/--pr).

The daemon URL is available instance-wide as the Forgejo Actions variable ROOST_SERVER, so ${{ vars.ROOST_SERVER }} resolves in every repository on CodeFloe without any per-repository setup. No token secret is needed either: the OIDC exchange replaces it.

The URL is a variable rather than a secret on purpose. Forgejo supports instance-wide (global) Actions variables, but deliberately does not support instance-wide secrets, so a shared value can only be distributed to every repository as a variable. That is a good fit here because the daemon URL is not a credential.

To point a single repository at a different daemon, define ROOST_SERVER as a repository or organization variable under Settings → Actions → Variables; the more specific definition wins over the instance-wide one.

name: preview

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

jobs:
  deploy:
    if: ${{ github.event.action != 'closed' }}
    runs-on: docker
    # allow this job to mint an OIDC id token
    enable-openid-connect: true
    container:
      image: node:lts-alpine
    steps:
      - uses: actions/checkout@<version>
      - name: deploy preview
        env:
          ROOST_SERVER: ${{ vars.ROOST_SERVER }}
        run: |
          apk add --no-cache curl jq
          npm ci
          # emits ./dist
          npm run build
          # install the roost client
          curl -fsSLo /usr/local/bin/roost \
            "https://codefloe.com/pat-s/roost/releases/download/<version>/roost-<version>-linux-amd64"
          chmod +x /usr/local/bin/roost
          # exchange the Forgejo OIDC token for the roost bearer token
          ROOST_TOKEN=$(curl -fsSL -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$ROOST_SERVER" | jq -r '.value')
          export ROOST_TOKEN
          roost deploy --repo "${{ github.repository }}" --pr "${{ github.event.number }}" --dir ./dist --ttl 168h

  destroy:
    if: ${{ github.event.action == 'closed' }}
    runs-on: docker
    enable-openid-connect: true
    container:
      image: node:lts-alpine
    steps:
      - name: destroy preview
        env:
          ROOST_SERVER: ${{ vars.ROOST_SERVER }}
        run: |
          apk add --no-cache curl jq
          curl -fsSLo /usr/local/bin/roost \
            "https://codefloe.com/pat-s/roost/releases/download/<version>/roost-<version>-linux-amd64"
          chmod +x /usr/local/bin/roost
          ROOST_TOKEN=$(curl -fsSL -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$ROOST_SERVER" | jq -r '.value')
          export ROOST_TOKEN
          roost destroy --repo "${{ github.repository }}" --pr "${{ github.event.number }}"

A container preview replaces the uploaded directory with an image and a port. Everything else stays as it is above: the same lifecycle events, the same derived name, the same URL, the same teardown.

The application has to listen on 0.0.0.0:<port> inside the container, not on 127.0.0.1, or the reverse proxy in front of it cannot reach it.

Build the image to a .tar file in the same workspace and hand that file to roost. Nothing is pushed, so the pipeline needs no registry credentials and the throwaway image never touches your package quota.

On Crow CI, the buildx plugin writes the archive and the roost step uploads it:

when:
  - event: [pull_request, pull_request_closed, pull_request_merged]

steps:
  build-image:
    image: codefloe.com/crow-plugins/docker-buildx:<version>
    settings:
      context: .
      # build only, do not push
      dry_run: true
      output: type=docker,dest=image.tar
      repo: app
      tag: pr-${CI_COMMIT_PULL_REQUEST}
    when:
      - event: pull_request

  preview:
    image: codefloe.com/crow-plugins/roost:<version>
    settings:
      server:
        from_secret: roost_server
      token:
        from_secret: roost_token
      image_archive: ./image.tar
      port: 4321
      ttl: 168h
    when:
      - event: [pull_request, pull_request_closed, pull_request_merged]

On Forgejo Actions, build and deploy have to live in the same job, so the archive stays on disk instead of travelling through upload-artifact, which would be slow and spend artifact storage:

jobs:
  preview:
    if: ${{ github.event.action != 'closed' }}
    runs-on: docker
    enable-openid-connect: true
    steps:
      - uses: actions/checkout@<version>
      # install the roost client and exchange the OIDC token as above
      - uses: docker/setup-buildx-action@<version>
      - uses: docker/build-push-action@<version>
        with:
          context: .
          push: false
          tags: app:pr-${{ github.event.number }}
          outputs: type=docker,dest=/tmp/image.tar
      - name: deploy preview
        env:
          ROOST_SERVER: ${{ vars.ROOST_SERVER }}
        run: |
          roost deploy --repo "${{ github.repository }}" --pr "${{ github.event.number }}" \
            --image-archive /tmp/image.tar --port 4321 --ttl 168h

The build must be single-platform. type=docker rejects a multi-arch build, and a preview only ever runs on the daemon’s architecture anyway.

An archive may be at most 2 GiB. roost loads it, runs it, and deletes the image again when the preview is replaced or destroyed.

Container previews take environment variables, so an app can be pointed at a staging API, put in debug mode, or given the credentials it needs to boot.

In Crow CI, env is a mapping and its values may come from_secret, so nothing sensitive is written into the pipeline file:

settings:
  image_archive: ./image.tar
  port: 4321
  env:
    LOG_LEVEL: debug
    API_TOKEN:
      from_secret: preview_api_token
  # or read KEY=VALUE lines from files an earlier step wrote
  env_file:
    - ./preview.env

In Forgejo Actions, pass them on the command line. A bare name takes its value from the step’s own environment, which keeps a secret out of the process list where --env KEY=VALUE would leave it readable:

env:
  ROOST_SERVER: ${{ vars.ROOST_SERVER }}
  API_TOKEN: ${{ secrets.PREVIEW_API_TOKEN }}
run: |
  roost deploy --repo "${{ github.repository }}" --pr "${{ github.event.number }}" \
    --image-archive /tmp/image.tar --port 4321 \
    --env API_TOKEN --env LOG_LEVEL=debug

Files are read first, in the order given, then the individual variables, so an explicit variable overrides the same name coming from a file. Environment variables apply to container previews only; a static preview has no process to give them to.

roost does not store these values, does not log them, and does not return them when listing previews. They are, however, part of the container’s configuration on the daemon host.

roost waits for a container preview to actually start serving before it calls the deploy a success. Once the container is up, it polls the published port until the app answers an HTTP request, and only then publishes the URL.

If nothing answers within 30 seconds, the deploy fails, the container is removed, and the error carries the last lines the container wrote:

error: roostd: preview did not start serving: nothing served on port 32768 after 30s: Get "http://127.0.0.1:32768/": context deadline exceeded; last output from the container:
Validation failed:
[config] api.token is required (set API_TOKEN env var or config.yml)

That output is usually the whole diagnosis: a missing environment variable, a crash on boot, or an app bound to 127.0.0.1 instead of 0.0.0.0. Any HTTP response counts as serving, whatever its status, so a preview answering 404 is considered up.

A job that fails here means no preview was published, rather than a green pipeline pointing reviewers at a URL that only ever returns 502.

Each container preview runs with 512 MiB of memory, one CPU, and at most 256 processes. It drops all capabilities and cannot gain new privileges.

An app that needs materially more than that is not a good fit for a shared preview host: build it as a static site if you can, or run your own roost daemon.

Static previews are capped at 512 MiB compressed on upload and 1 GiB once extracted.