# CI/CD deployments

This guide walks through deploying a Flyte project from CI. It uses GitHub Actions as the reference implementation, but the building blocks (a non-interactive credential, `flyte deploy`, and a commit-pinned version) translate to GitLab CI, Buildkite, CircleCI, or any runner that can run a Python script.

The examples below assume the project layout and image definitions from the [Monorepo with uv](https://www.union.ai/docs/latest/flyte/user-guide/project-patterns/cicd/monorepo-with-uv) pattern; that guide covers how to structure `pyproject.toml`, `envs.py`, and task modules in a way that makes the `flyte deploy` commands shown here work cleanly.

## What CI needs to do

A deploy pipeline has three jobs:

1. **Install** the project and the `flyte` CLI.
2. **Authenticate** non-interactively against your instance.
3. **Run `flyte deploy`** for every `TaskEnvironment` in your project, pinned to the commit SHA.

Everything else (branch protections, approvals, notifications) is generic CI concerns and out of scope.

## Authentication: client credentials

Locally, `flyte deploy` typically authenticates via a browser login (PKCE). A CI runner has no browser and no human to click through a consent screen, so you need a credential the CLI can use without any prompts. For Flyte OSS this is an **OAuth2 client-credentials** application: a client ID and client secret for a machine ("service") identity that your instance's identity provider (IdP) trusts.

### Register a client-credentials application

Client-credentials applications are provisioned in your **identity provider**, not through the `flyte` CLI. (The `flyte create api-key` command referenced elsewhere is a Union feature from the `flyteplugins-union` package and isn't available in Flyte OSS.) The exact steps depend on the IdP your instance is configured against (Okta, Keycloak, Auth0, Google, Azure AD, and so on), but the result is always a **client ID** and a **client secret**.

Ask whoever administers your instance for:

- The **client ID** of a service application authorized to register tasks in the target project and domain.
- The matching **client secret**.
- The admin **endpoint**: the same host you pass to `flyte create config`.

> [!NOTE]
> The application must be granted whatever scopes your admin API requires. Provisioning and scoping the IdP application is an instance-administration task; see your instance's authentication setup for the specifics.

### Store the secret as a CI secret

Add the **client secret** to your CI system's secret store. However it's configured, the secret needs to:

- Be exposed to the deploy job as an environment variable (this guide uses `FLYTE_CLIENT_SECRET`).
- Be masked in logs (most CI systems do this automatically for secrets).
- Be scoped to the branches/environments that actually deploy: typically `main` or a release branch, not every feature branch or fork PR.

The client ID and endpoint aren't secret; they live in the `config.yaml` you check into the repo (see [below](#project-configuration)). Only the client secret goes in the secret store.

### Point the CLI at the credential

The `flyte` CLI reads client-credentials settings from `config.yaml` under `admin:`:

- **`authType: ClientSecret`** selects the OAuth2 client-credentials flow instead of the interactive PKCE default.
- **`clientId`** is the application's client ID.
- **`clientSecretEnvVar`** names the environment variable the CLI reads the secret from: `FLYTE_CLIENT_SECRET` here. (Alternatively, `clientSecretLocation` points at a file containing the secret, which suits runners that mount secrets as files rather than env vars.)

### Scope and rotation

Grant the service identity only the permissions CI needs: deploy rights on the target project/domain, nothing more. Rotate the client secret on a schedule (90 days is a reasonable default) in your IdP and update the CI secret to match.

## Project configuration

Two files drive `flyte deploy` behavior in CI: `pyproject.toml` (or `uv.lock`) for dependencies, and `config.yaml` for your endpoint and image-builder settings.

### `config.yaml`

Save this at `.flyte/config.yaml` (or `config.yaml`) in your repo and check it in. In CI the `flyte` CLI auto-discovers config from the repo checkout: repo-relative paths (`./config.yaml`, `./.flyte/config.yaml`, `<git-root>/.flyte/config.yaml`) take precedence over any home-directory config, so it's picked up automatically after checkout with no `--config` flag needed. See [the config discovery order](https://www.union.ai/docs/latest/flyte/user-guide/api-reference/flyte-sdk/flyte.config/_index#auto) for the full precedence; pass `--config <path>` only to point at a non-standard location.

It supplies the endpoint, the client-credentials auth settings, and the (local) image builder, everything `flyte deploy` needs beyond the client secret:

```yaml
admin:
  endpoint: dns:///<your-flyte-host>
  authType: ClientSecret
  clientId: <client-id>
  clientSecretEnvVar: FLYTE_CLIENT_SECRET
image:
  builder: local
task:
  project: <default-project>
  domain: development
```

The `clientId` and `endpoint` are safe to commit; only the client secret named by `clientSecretEnvVar` comes from the CI secret store. `builder: local` means images are built on the runner with Docker (Flyte OSS has no remote builder). See [Container images](https://www.union.ai/docs/latest/flyte/user-guide/project-patterns/tasks/task-configuration/container-images#image-building).

## The GitHub Actions workflow

A minimal deploy workflow, one job, one step per `TaskEnvironment`:

The client ID and endpoint live in the checked-in `config.yaml` (auto-discovered from the repo, as above); each deploy step injects the client secret from the CI secret store.

```yaml
# .github/workflows/deploy.yml
name: Deploy to Flyte

on:
  push:
    branches: [main]
  workflow_dispatch:

env:
  FLYTE_PROJECT: my-project
  FLYTE_DOMAIN: development

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true

      - name: Sync dependencies
        run: uv sync --group etl --group ml

      - name: Deploy etl_env
        env:
          FLYTE_CLIENT_SECRET: ${{ secrets.FLYTE_CLIENT_SECRET }}
        run: |
          uv run flyte deploy \
            --copy-style none \
            --version ${{ github.sha }} \
            --project "$FLYTE_PROJECT" \
            --domain "$FLYTE_DOMAIN" \
            src/workspace_app/tasks/etl_tasks.py etl_env

      - name: Deploy ml_env
        env:
          FLYTE_CLIENT_SECRET: ${{ secrets.FLYTE_CLIENT_SECRET }}
        run: |
          uv run flyte deploy \
            --copy-style none \
            --version ${{ github.sha }} \
            --project "$FLYTE_PROJECT" \
            --domain "$FLYTE_DOMAIN" \
            src/workspace_app/tasks/ml_tasks.py ml_env
```

Because Flyte OSS builds images locally, the deploy steps need Docker available on the runner (the `ubuntu-latest` image includes it) and access to your container registry; add a `docker login` step for private registries before the first deploy.

### Key flag choices

- **`--copy-style none`**: bakes source into the image as part of the build layer. Combined with `.with_code_bundle()` on your `flyte.Image` (see [Monorepo with uv](https://www.union.ai/docs/latest/flyte/user-guide/project-patterns/cicd/monorepo-with-uv)), this resolves to a `COPY` instruction so the image is fully self-contained. This is the production path: one immutable artifact per commit, no runtime code bundle download.
- **`--version ${{ github.sha }}`**: makes deploys idempotent and traceable. Re-running the same commit produces the same version identifier; tasks already registered at that version are no-ops.
- **Path argument points at the task file, not `envs.py`.** `flyte deploy` only imports the file you give it, so tasks decorated with `@env.task` in separate files won't register unless you point at (or transitively import) those files. Pointing at `etl_tasks.py` pulls in `envs.py` via its import chain and runs the `@etl_env.task` decorators. As an alternative, you can point at a directory and pass `--recursive` to load every task module under it in one command. For a `src/` layout project, also pass `--root-dir src` so shared modules like `envs.py` resolve to a single import path instead of being loaded twice:

  ```yaml
  - name: Deploy all envs
    env:
      FLYTE_CLIENT_SECRET: ${{ secrets.FLYTE_CLIENT_SECRET }}
    run: |
      uv run flyte deploy \
        --copy-style none \
        --version ${{ github.sha }} \
        --project "$FLYTE_PROJECT" \
        --domain "$FLYTE_DOMAIN" \
        --root-dir src --recursive src/workspace_app/tasks
  ```

### Splitting build from deploy

`flyte deploy` builds any missing images before it registers tasks. If you'd rather treat image builds as a separate CI concern (for clearer logs, independent retry, or parallel builds per env), run `flyte build` first and let deploy reuse the result:

```yaml
- name: Build etl image
  env:
    FLYTE_CLIENT_SECRET: ${{ secrets.FLYTE_CLIENT_SECRET }}
  run: |
    uv run flyte build \
      --copy-style none --root-dir src \
      src/workspace_app/tasks/etl_tasks.py etl_env

- name: Deploy etl_env
  env:
    FLYTE_CLIENT_SECRET: ${{ secrets.FLYTE_CLIENT_SECRET }}
  run: |
    uv run flyte deploy \
      --copy-style none \
      --version ${{ github.sha }} \
      --project "$FLYTE_PROJECT" --domain "$FLYTE_DOMAIN" \
      --root-dir src src/workspace_app/tasks/etl_tasks.py etl_env
```

Image tags are content hashes of the `flyte.Image` definition: `flyte build` pushes `<registry>:flyte-<hash>`, and `flyte deploy` computes the same hash, sees the image already in the registry, and skips rebuilding. `--copy-style` must match between the two commands; otherwise the hashes diverge and deploy will build again.

---
**Source**: https://github.com/unionai/unionai-docs/blob/main/content/user-guide/project-patterns/cicd.md
**HTML**: https://www.union.ai/docs/latest/flyte/user-guide/project-patterns/cicd/
