# CI/CD integration

Run the dcupl CLI inside your pipeline to push workspace changes automatically on every merge. The pattern is the same on every platform: install the CLI, expose your project ID and API key from your CI secrets as environment variables, then push. This page shows the core pattern, what the push changes in the cloud, and ready-to-copy configs for the major platforms.

## The pattern

Run the job from the workspace root, the folder that holds `dcupl.config.json`: the CLI reads that file, and the files under its `baseFolder` (default `dcupl/`), from the current directory. In a folder without a `dcupl.config.json` the push uses the defaults (`baseFolder` `dcupl`, include `**/*.json`, `**/*.csv` and `**/*.js`) and says so on stderr.

```bash
# DCUPL_PROJECT_ID and DCUPL_API_KEY come from your CI secrets as environment variables
dcupl files push --force --yes
```

The `files` commands read `DCUPL_PROJECT_ID` and `DCUPL_API_KEY` straight from the environment, so the job needs no `dcupl config set` step. Use the project's **CLI** key (type CLI read/write, see [API keys](/console/api-keys)): `files status`, `push` and `pull` reject every other key type. `--yes` keeps the job non-interactive.

## What `--force` overwrites

A CI job starts from a fresh checkout, so it has no sync baseline: the CLI records the last-synced state in `.dcupl/sync-state/`, which belongs to one checkout and is never committed. Without a baseline, push can't tell your changes from edits made in the Console. Every file whose content differs from the server copy counts as a conflict, and a push without `--force` aborts with `PUSH_CONFLICTS_NO_BASELINE` (exit 1) before it uploads anything. `--force` resolves each conflict by uploading the repository copy.

On a fresh checkout, `dcupl files push --force` does this to the target version (`draft`, unless you pass `--version <id>`):

| File                            | What happens                                                                                            |
| ------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Only in the repository          | Uploaded                                                                                                |
| In both, with different content | Uploaded: the repository copy **replaces the server copy, including Console edits since the last push** |
| In both, with the same content  | Skipped                                                                                                 |
| Only on the server              | Kept. Nothing is deleted, with or without `--strict`                                                    |

Push never changes your files, and it never touches `production`: it rejects that version (promote with `dcupl files versions copy`). Add `--path <folder>` to limit the push, and what it can overwrite, to part of the workspace.

> **Watch out:** **The repository wins.** Every run overwrites the target version's files with the repository copy, so Console edits to those files are lost on the next push. Edit that version only in the repository, or pull Console edits into it with `dcupl files pull` (`--force` in a checkout that has never synced) and commit them before the next run.

Deletions are not mirrored: `--strict` deletes a server file only when the baseline shows it was deleted locally, and a CI checkout has no baseline. Remove a file from the cloud version with `dcupl files delete --path <path> --yes`, or in the Console.

> **Watch out:** Never commit `dcupl.secrets.json` or `.env`. Inject the API key from your CI provider's secrets at runtime as the `DCUPL_API_KEY` environment variable. Make sure `.gitignore` lists `dcupl.secrets.json` and `.env`. `dcupl init <folder>` writes a `.gitignore` that does, but `dcupl init --here` keeps a `.gitignore` that is already in the folder and adds neither. The CLI keeps `.dcupl/` out of Git itself.

If you'd rather have a credentials step, `dcupl config set --project-id "$DCUPL_PROJECT_ID" --api-key "$DCUPL_API_KEY" --yes` writes the project ID to `dcupl.config.json` and the key to `.env` (a fresh checkout has no `dcupl.secrets.json`). Run it only where `dcupl.config.json` is committed: without one, it creates a config whose `filesUpload.include` lacks `**/*.js`, so scripts, operators and transformers are not pushed.

For a self-hosted Console, commit `consoleApiUrl` in `dcupl.config.json` (`dcupl config set --console-api-url <url>` writes it). There is no environment variable for it: unlike `DCUPL_API_KEY` and `DCUPL_PROJECT_ID`, `consoleApiUrl` is read from the config file only.

## Gate the push with validation

Optionally run `dcupl validate` first and fail the job before any files are uploaded. Remember that `validate` exits 0 whenever it produces a report, even one with errors, and that the report's top-level `ok` is always `true` — check `summary.critical` and `load.ok` instead. It exits 1 only when it cannot produce a report (for example, a malformed loader config), so keep the exit-code check too (see [Validating a project](/cli/validate)):

```bash
report=$(dcupl validate --json) || { echo "$report"; exit 1; }
loaded=$(echo "$report" | jq -r '.load.ok')
critical=$(echo "$report" | jq -r '.summary.critical')
if [ "$loaded" != "true" ] || [ "$critical" -gt 0 ]; then
  echo "Validation failed — aborting push" && exit 1
fi
dcupl files push --force --yes
```

## Platform examples

### GitHub Actions

```yaml filename=".github/workflows/deploy.yml"
name: Deploy to dcupl

on:
  push:
    branches: [main]

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

      - uses: actions/setup-node@v7
        with:
          node-version: '24'

      - run: npm install -g @dcupl/cli

      - name: Push to dcupl
        run: dcupl files push --force --yes
        env:
          DCUPL_PROJECT_ID: ${{ secrets.DCUPL_PROJECT_ID }}
          DCUPL_API_KEY: ${{ secrets.DCUPL_API_KEY }}
```

Store `DCUPL_PROJECT_ID` and `DCUPL_API_KEY` as repository secrets under **Settings → Secrets and variables → Actions**.

### GitLab CI

```yaml filename=".gitlab-ci.yml"
deploy:
  image: node:24
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    - npm install -g @dcupl/cli
    - dcupl files push --force --yes
```

Define `DCUPL_PROJECT_ID` and `DCUPL_API_KEY` as masked CI/CD variables under **Settings → CI/CD → Variables**. GitLab passes them to the job as environment variables.

### CircleCI

```yaml filename=".circleci/config.yml"
version: 2.1

jobs:
  deploy:
    docker:
      - image: cimg/node:24.21
    steps:
      - checkout
      - run: npm install -g @dcupl/cli
      - run:
          name: Push to dcupl
          command: dcupl files push --force --yes

workflows:
  deploy-on-main:
    jobs:
      - deploy:
          filters:
            branches:
              only: main
```

Set `DCUPL_PROJECT_ID` and `DCUPL_API_KEY` as project environment variables in the CircleCI dashboard. CircleCI passes them to the job as environment variables.

## Next steps

- [Cloud sync & configuration](/cli/cloud-sync) — The full file sync command reference
- [Workflows](/cli/workflows) — Deploy workflow templates from CI
- [Commands Reference](/cli/commands) — All CLI commands
