# Deploy from your own CI/CD

**Goal:** deploy a [Front End Hosting](/start-here/glossary/#front-end-hosting) – Advanced app from GitHub Actions (or any CI system you already run) using the prebuilt-[artifact](/start-here/glossary/#build-artifact) flow. Your own CI and [Code Studio](/start-here/glossary/#code-studio) are the two equal deploy paths for Front End Hosting frontends; on this one, the deploy is a built artifact plus one API call, and your existing CI does both directly.

## What you'll have when you're done

- An `acquia_config.yaml` with `enable-prebuilt-artifact: true`, telling the platform you ship finished builds.
- A CI job that builds your app, assembles a deployable artifact, and force-pushes it to your application's Acquia git remote.
- A deploy step that switches an [environment](/start-here/glossary/#environment) to that artifact via the Cloud Platform API, authenticated with an OAuth `client_credentials` token.

## Prerequisites

- A Cloud Platform application with a Front End Hosting – Advanced [entitlement](/start-here/glossary/#entitlement).
- Cloud Platform API credentials (client ID + secret). Create them as shown in the [Cloud Platform API page](/cloud-platform/cli/platform-api/). These are Cloud Platform API credentials, not your site's `DRUPAL_CLIENT_ID`/`DRUPAL_CLIENT_SECRET` Content API pair.
- An [SSH key](/start-here/glossary/#ssh-key) pair whose public key is added to your Acquia profile, plus your application's git repository URL (on the application's overview page in the Cloud Platform user interface) and the target environment ID, shaped like `123456-a1b2c3d4-5678-90ab-cdef-1234567890ab`: `acli api:applications:environment-list <app> | jq '.[] | {name, id}'` lists them.

<details>
<summary>Never set up an SSH key?</summary>

Generate a key pair with `ssh-keygen -t ed25519` (accept the defaults), then register the **public** half with Acquia: `acli ssh-key:create-upload` generates and registers in one step; for the Cloud UI equivalent, see [Adding a public key to an Acquia profile](https://docs.acquia.com/acquia-cloud-platform/adding-public-key-acquia-profile) on docs.acquia.com. The **private** half (`~/.ssh/id_ed25519`) is what a CI secret like `DEPLOY_SSH_KEY` holds: paste the whole file including the `-----BEGIN` and `-----END` lines; a mangled newline is the classic cause of `Permission denied (publickey)`.

</details>
- A CI environment with Linux amd64, [Node.js](https://nodejs.org/) 22 or later, npm, [git](https://git-scm.com/downloads), [`curl`](https://curl.se/), and [`jq`](https://jqlang.org/).

## Steps

<Steps>

1. ### Declare prebuilt artifacts in `acquia_config.yaml`

   In the root directory of the branch you deploy, set:

   ```yaml
   # acquia_config.yaml
   enable-prebuilt-artifact: true
   ```

   The default is `false`; without this flag the platform expects to build for you. The file must ship inside the artifact you push.

2. ### Add the secrets to your CI system

   In GitHub Actions: `Settings > Secrets and variables > Actions`. You need:

   | Secret | Value |
   |---|---|
   | `DEPLOY_SSH_KEY` | Private SSH key for pushing to the Acquia git repository |
   | `ACQUIA_GIT_URL` | Your application's Acquia git repository URL |
   | `ACQUIA_CLOUD_API_KEY` / `ACQUIA_CLOUD_API_SECRET` | Cloud Platform API credentials ([get them here](/cloud-platform/cli/platform-api/)) |
   | `ENVIRONMENT_ID` | ID of the environment to deploy |
   | `DRUPAL_SITE_URL` / `DRUPAL_CLIENT_ID` / `DRUPAL_CLIENT_SECRET` | Your site's Content API credentials, if the build renders content (step 3) |

   That last row is the one that is easy to miss: those are the same three values your dev server reads from `.env`, and the build needs them for the reason step 3 explains. `DRUPAL_SITE_URL` is not itself a secret, so a repository variable works for it; the ID and the secret are.

3. ### Build the app and assemble the artifact

   Build on Linux amd64 with a supported Node.js version, then copy *only* what the running app needs into a clean directory, including `node_modules` and `acquia_config.yaml`:

   ```bash
   npm install
   npm run build

   rm -rf build-output
   mkdir build-output
   # Next.js example - adjust the file list for your framework
   cp -R .next public package.json \
     next.config.ts node_modules \
     acquia_config.yaml build-output/
   ```

   Two things fail here, both on the `npm run build` line and both fatal to the job rather than merely noisy:

   **The build runs your content fetches.** A page that fetches at the top level is prerendered at build time, so the build makes the same authenticated calls the dev server did and fails without the credentials to make them. That is what the `DRUPAL_SITE_URL`/`DRUPAL_CLIENT_ID`/`DRUPAL_CLIENT_SECRET` row in step 2 is for: put all three in the build step's `env:`, as the workflow in step 5 does. With the three variables absent the build dies with `Failed to collect page data for /`, and with `DRUPAL_SITE_URL` set to a host it cannot reach it dies with `Error occurred prerendering page "/"` and `getaddrinfo ENOTFOUND`. A CI runner reaching your site over the network is a prerequisite of this flow, not an optimization.

   **The config filename is whatever your scaffold wrote.** `create-next-app --ts` writes `next.config.ts`, so that is the name in the list above; copy `next.config.js` instead and `cp` exits non-zero on the missing file, which fails the whole step under the `-e` shell GitHub Actions runs. Check your own repository and copy the file that is actually there.

4. ### Push the artifact to the Acquia git remote

   Initialize a fresh repository inside the artifact directory and force-push it as the deployment branch:

   ```bash
   cd build-output
   git init
   git config user.name "ci-bot"
   git config user.email "ci@example.com"
   git remote add target "$ACQUIA_GIT_URL"

   mkdir -p ~/.ssh
   echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_rsa
   chmod 600 ~/.ssh/id_rsa
   HOST=$(echo "$ACQUIA_GIT_URL" | \
     sed 's/.*@\([^:]*\):.*/\1/')
   ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts

   git checkout -b main-build
   # Delete every .gitignore before staging: the source repo's
   # ignore rules are correct for source control and wrong for a
   # deploy artifact: with them present, git silently excludes
   # the very files (node_modules, dotfiles) the server needs.
   find . -name ".gitignore" -not -path "./.git/*" -delete
   git add -A
   git commit -m "Build output from CI"
   git push target main-build -f
   ```

   For tag-based deploys, `git tag "$TAG_NAME" && git push --force target "$TAG_NAME"` instead of the branch push.

   Two refinements from production use:

   - Fetching the existing deploy branch first (`git fetch target main-build && git checkout -b main-build target/main-build`, falling back to a fresh branch) preserves deploy history instead of orphaning it.
   - `--force-with-lease` (with plain `--force` as the fallback) is the safer push when the deploy branch does exist.

   And if your CI passes the build between jobs as a stored artifact (GitHub's `actions/upload-artifact`), pass `include-hidden-files: true`: v4 silently drops dotfiles otherwise, and a deploy missing its dotfiles fails in confusing ways.

5. ### Trigger the deploy via the Cloud Platform API

   Exchange the Cloud API credentials for a token with the OAuth `client_credentials` grant, then call the code-switch endpoint:

   ```bash
   TOKEN=$(curl -s -L -X POST \
     "https://accounts.acquia.com/api/auth/oauth/token" \
     -H "content-type: application/x-www-form-urlencoded" \
     -d grant_type=client_credentials \
     -d client_id="${ACQUIA_CLOUD_API_KEY}" \
     --data-urlencode client_secret="${ACQUIA_CLOUD_API_SECRET}" \
     | jq -r .access_token)

   curl -s --fail -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Content-Type: application/json" \
     -H "Accept: application/hal+json" \
     -d '{"branch": "main-build"}' \
     https://cloud.acquia.com/api/environments/${ENVIRONMENT_ID}/code/actions/switch
   ```

   A successful call returns `202 Accepted` with a message and a notification link (trimmed):

   ```json
   {
     "message": "The code is being switched.",
     "_links": {
       "notification": { "href": "https://cloud.acquia.com/api/notifications/…" }
     }
   }
   ```

   The switch call only *queues* a deployment task: the code is live once that task completes, typically within a few minutes. Watch it finish in the environment's task log (the bell/task list on the environment page in the Cloud UI), or poll the notification URL from the response. Verifying the site URL before the task completes shows the previous deploy and proves nothing.

   `--fail` makes `curl` exit non-zero on an HTTP error so a bad token or wrong environment ID fails the CI job instead of scrolling past.

   To deploy a tag, prefix the name with `tags/` in the `branch` field: `{"branch": "tags/v1.0.1"}`.

   If you'd rather not hand-roll the token exchange, [`acli`](/start-here/glossary/#acli) wraps the same API call:

   ```bash
   curl -sSL https://github.com/acquia/cli/releases/latest/download/acli.phar -o /usr/local/bin/acli
   chmod +x /usr/local/bin/acli
   acli auth:login --key="$ACQUIA_CLOUD_API_KEY" --secret="$ACQUIA_CLOUD_API_SECRET" --no-interaction  # acli calls the same pair key and secret
   acli api:environments:code-switch "$ENVIRONMENT_ID" main-build --no-interaction
   ```

   The branch is a positional argument; `--branch=` fails with `The --branch option does not exist`.

   Put together as a single GitHub Actions workflow:

   ```yaml
   # .github/workflows/deploy-acquia.yml
   name: Deploy to Acquia
   on:
     push:
       branches: [main]
   jobs:
     build-and-deploy:
       # Linux amd64, as Acquia requires
       runs-on: ubuntu-latest
       steps:
         - uses: actions/checkout@v4
         - uses: actions/setup-node@v4
           with:
             node-version: 22
         - name: Build artifact
           # The build prerenders pages that fetch content, so it needs
           # the site credentials, not only the deploy ones.
           env:
             DRUPAL_SITE_URL: ${{ secrets.DRUPAL_SITE_URL }}
             DRUPAL_CLIENT_ID: ${{ secrets.DRUPAL_CLIENT_ID }}
             DRUPAL_CLIENT_SECRET: ${{ secrets.DRUPAL_CLIENT_SECRET }}
           run: |
             npm install
             npm run build
             rm -rf build-output && mkdir build-output
             cp -R .next public package.json \
               next.config.ts node_modules \
               acquia_config.yaml build-output/
         - name: Push artifact to Acquia
           env:
             DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
             ACQUIA_GIT_URL: ${{ secrets.ACQUIA_GIT_URL }}
           run: |
             mkdir -p ~/.ssh
             echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_rsa
             chmod 600 ~/.ssh/id_rsa
             HOST=$(echo "$ACQUIA_GIT_URL" | \
               sed 's/.*@\([^:]*\):.*/\1/')
             ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts
             cd build-output
             git init
             git config user.name "ci-bot"
             git config user.email "ci@example.com"
             git remote add target "$ACQUIA_GIT_URL"
             git checkout -b main-build
             # Delete every .gitignore before staging (step 4):
             # surviving ignore rules silently exclude the files
             # (node_modules, dotfiles) the server needs.
             find . -name ".gitignore" -not -path "./.git/*" -delete
             git add .
             MSG="Build output from CI ($GITHUB_SHA)"
             git commit -m "$MSG"
             git push target main-build -f
         - name: Trigger deploy via Cloud Platform API
           env:
             ACQUIA_CLOUD_API_KEY: ${{ secrets.ACQUIA_CLOUD_API_KEY }}
             ACQUIA_CLOUD_API_SECRET: ${{ secrets.ACQUIA_CLOUD_API_SECRET }}
             ENVIRONMENT_ID: ${{ secrets.ENVIRONMENT_ID }}
           run: |
             TOKEN=$(curl -s -L -X POST \
               "https://accounts.acquia.com/api/auth/oauth/token" \
               -H "content-type: application/x-www-form-urlencoded" \
               -d grant_type=client_credentials \
               -d client_id="${ACQUIA_CLOUD_API_KEY}" \
               --data-urlencode \
               client_secret="${ACQUIA_CLOUD_API_SECRET}" \
               | jq -r .access_token)
             curl -s --fail -X POST \
               -H "Authorization: Bearer $TOKEN" \
               -H "Content-Type: application/json" \
               -H "Accept: application/hal+json" \
               -d '{"branch": "main-build"}' \
               https://cloud.acquia.com/api/environments/${ENVIRONMENT_ID}/code/actions/switch
   ```

6. ### Verify the deployment

   In the Cloud UI, open the target environment and check its task log (the task list on the environment page): confirm a commit-based deployment task was created and completed, and that `main-build` is the deployed branch. The response from the switch call also returns a notification URL you can poll via the API.

   Know where run status lives with this flow: your CI system is the source of truth for build and deploy runs, and the environment's task log shows the switch. The Cloud UI's `Pipelines` section stays empty; that's expected (nothing here runs through [Pipelines](/start-here/glossary/#pipelines)), not a sign the deploy failed to register.

</Steps>

## What just happened

You split CI/CD into two halves the platform understands. The *artifact push* is plain git: because `enable-prebuilt-artifact: true` ships in the artifact, the platform serves what you pushed instead of trying to build it. The *deploy trigger* is one Cloud Platform API call (`POST /api/environments/{environmentId}/code/actions/switch`), authorized by a `client_credentials` token from `accounts.acquia.com`, exactly the same grant your [site API client](/start-here/glossary/#api-client) uses, pointed at the platform's account service instead of your site.

The runner does all four steps:

- Force-pushes the built artifact to the Acquia git remote.
- Exchanges the Cloud API credentials for a token.
- Calls the code-switch endpoint, which returns `202` and *queues* a deploy task.
- Polls the notification URL until that task completes and the code is live.

<ConceptDiagram name="external-ci-deploy" height={520} />

## Multiple environments and a production gate

The workflow above hardcodes one `ENVIRONMENT_ID`, which is fine until Stage and Prod enter the picture. Two GitHub Actions features carry the whole pattern; other CI systems have equivalents:

**Parameterize the environment instead of copying the workflow.** GitHub *environments* (`Settings > Environments`) each hold their own `ENVIRONMENT_ID` secret under the same name; the job picks one by declaring which environment it deploys. One workflow, one artifact recipe, N targets:

```yaml
jobs:
  deploy-stage:
    if: github.ref == 'refs/heads/main'
    environment: stage        # reads stage's ENVIRONMENT_ID
    # ...build, push, switch as above...

  deploy-production:
    if: startsWith(github.ref, 'refs/tags/')
    environment: production # reads production ENVIRONMENT_ID
    # ...same steps, deploying the tag:
    # {"branch": "tags/${{ github.ref_name }}"}
```

Branch pushes deploy stage; tags deploy production, mirroring the promotion model from the [CI/CD guide](/cloud-platform/ci-cd/guide/#promote-through-environments).

**Gate the production switch.** On the `production` environment, enable *required reviewers*: the job then pauses before its steps run, and the switch call happens only after a human approves the run. Combined with the tag condition, production changes require both a deliberate tag and an approval; no push alone can do it.

Keep one artifact per target (`stage-built`, or the tag name) so a pending production deploy is never overwritten by the next stage build.

**Build once, promote the same artifact.** The strongest version of this pattern splits the workflow into reusable pieces (`workflow_call`): one build job produces a stored artifact, and one parameterized deploy job runs three times against it: dev automatically, test and production behind their environment approvals. What reaches production is then byte-for-byte what was approved on test, not a rebuild of the same commit.

Two supporting details:

- Add a `workflow_dispatch` trigger with an environment choice so hotfixes can deploy any branch to any environment on demand.
- Set a per-branch `concurrency` group with `cancel-in-progress` disabled for production so a new push never cancels a production deploy mid-flight.

**Verify the wiring before the first real deploy.** A manual-only diagnostic workflow saves the first-deploy debugging session: load the SSH key, `git ls-remote` the Acquia remote (proves SSH auth), and run the token exchange or `acli auth:login` (proves the API credentials). Every failure mode surfaces in isolation instead of mid-deploy.

If a gated deploy still goes wrong, roll back the same way you deployed. For tag deploys, switch back to the previous tag. For branch artifacts, re-run this workflow from the last good commit (or redeploy the stored artifact of the last good run, if your CI retains it).

## Deploying a Drupal application this way

The same flow deploys a Drupal codebase from your own CI. The recommended CI for Cloud Platform Drupal work is [Pipelines](/start-here/glossary/#pipelines) ([CI/CD guide](/cloud-platform/ci-cd/guide/)), but the bring-your-own (BYO) flow is a working, documented option, and everything above applies with a Drupal-shaped artifact instead of a Node.js one:

1. **Build the artifact**: `composer install --no-dev --optimize-autoloader --no-scripts`, plus whatever compiles your theme assets. `--no-dev` keeps phpunit/phpstan/phpcs out of production; strip their config files and any `node_modules` from the artifact too.
2. **Restore the Drupal scaffold files.** `--no-scripts` skips the Composer scaffold, and `docroot/.gitignore` excludes `.htaccess`, `index.php`, `robots.txt`, and `autoload.php` from source control, so none of them are in your checkout. Without `.htaccess`, every URL except `/` 404s. Copy them back from `docroot/core/assets/scaffold/files/` before packaging, and write `docroot/autoload.php` as a shim (`<?php return require __DIR__ . '/../vendor/autoload.php';`), never a copy of `vendor/autoload.php`, whose relative paths only resolve from inside `vendor/`.
3. **Name the deploy branch `pipelines-build-<branch>`** if the application previously deployed via Pipelines (note the plural `pipelines-`). Matching the convention keeps the environment's history continuous and existing [Cloud Hooks](/start-here/glossary/#cloud-hooks) wiring untouched.
4. **Push and switch** exactly as above; the code switch fires the application's `post-code-deploy` hook, which is where `config:import`/`updb`/`cache:rebuild` belong ([code-workflow guide](/cloud-platform/code-workflow/guide/#automate-deploy-events-with-cloud-hooks)).

## When something goes wrong

**`error` in the token response from `accounts.acquia.com`**: the Cloud API client ID or secret is wrong, or the credentials were revoked. Regenerate them as in the [Cloud Platform API page](/cloud-platform/cli/platform-api/) and update the CI secrets. Note these are Cloud Platform API credentials, not your site's Content API pair.

**`Permission denied (publickey).` when pushing to the Acquia remote**: the `DEPLOY_SSH_KEY` secret doesn't match a public key on your Acquia profile, or the key file permissions are wrong (`chmod 600`). Also confirm `ssh-keyscan` added the Acquia host to `known_hosts`.

**The switch call succeeds for a tag but nothing deploys**: tags must be prefixed in the `branch` field: `"branch": "tags/v1.0.1"`, not `"branch": "v1.0.1"`.

**`cp: next.config.js: No such file or directory`, and the build step fails there**: the file list names a config the repository doesn't have. A `--ts` Next.js scaffold writes `next.config.ts`. Copy the name that exists; `cp` exits non-zero on a missing source, and GitHub Actions runs each `run:` block under `-e`, so nothing after that line executes.

**`Error occurred prerendering page "/"`, `Failed to collect page data for /`, or `getaddrinfo ENOTFOUND` during `npm run build`**: the build is running your content fetches and has no credentials, or no route to the site. Add `DRUPAL_SITE_URL`, `DRUPAL_CLIENT_ID`, and `DRUPAL_CLIENT_SECRET` to the build step's `env:` (step 3), and confirm the runner can reach the site: a site behind an IP allowlist needs the runner's egress addresses on it, or a self-hosted runner inside the network.

**`Deployment failed: The output directory specified in acquia_config.yaml/acquia_config.yml does not exist in the current branch.`** The artifact you pushed doesn't contain the directory named in `output_directory`. Check the `cp -R` list in your build step; the built output and `acquia_config.yaml` must both land inside `build-output/`.

**The switch succeeds but the deployed app is missing dependencies (or, for Drupal, fatals on bootstrap with `vendor/` absent)**: a `.gitignore` inside the artifact excluded them from the deploy commit. Delete every `.gitignore` in the artifact directory before `git add -A` (step 4); confirm with `git ls-tree -r <deploy-branch> --name-only | grep vendor` against the Acquia remote.

**Every URL except `/` returns 404 on a deployed Drupal site**: `docroot/.htaccess` never reached the server: it's scaffold-managed, git-ignored, and (as a dotfile) silently dropped by GitHub's artifact storage unless `include-hidden-files: true` is set. Restore the scaffold files in the build step and check the artifact upload options.

**`The "--branch" option does not exist.` from `acli api:environments:code-switch`**: the branch is a positional argument, not a flag: `acli api:environments:code-switch <env-id> <branch>`.

**The Cloud UI's Pipelines section shows nothing after a deploy**: expected. BYO runs never appear there; check your CI system for the run and the environment's task log for the switch.

## Next steps

- [Call the Cloud Platform API](/cloud-platform/cli/platform-api/): create the Cloud Platform API client this flow authenticates with.
- [CI/CD guide](/cloud-platform/ci-cd/guide/): the full landscape, promotion, and governance patterns this flow slots into.
