Skip to content

Deploy from your own CI/CD

Goal: deploy a Front End Hosting – Advanced app from GitHub Actions (or any CI system you already run) using the prebuilt-artifact flow. Your own CI and 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.

  • 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 to that artifact via the Cloud Platform API, authenticated with an OAuth client_credentials token.
  • A Cloud Platform application with a Front End Hosting – Advanced entitlement.
  • Cloud Platform API credentials (client ID + secret). Create them as shown in the Cloud Platform API page. These are Cloud Platform API credentials, not your site’s DRUPAL_CLIENT_ID/DRUPAL_CLIENT_SECRET Content API pair.
  • An 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.
Never set up an SSH key?

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 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).

  • A CI environment with Linux amd64, Node.js 22 or later, npm, git, curl, and jq.
  1. Declare prebuilt artifacts in acquia_config.yaml

    Section titled “Declare prebuilt artifacts in acquia_config.yaml”

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

    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. 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)
    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 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:

    Terminal window
    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

    Section titled “Push the artifact to the Acquia git remote”

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

    Terminal window
    cd build-output
    git init
    git config user.name "ci-bot"
    git config user.email "[email protected]"
    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

    Section titled “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:

    Terminal window
    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):

    {
    "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 wraps the same API call:

    Terminal window
    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:

    .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 "[email protected]"
    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. 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), not a sign the deploy failed to register.

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 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.

Multiple environments and a production gate

Section titled “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:

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.

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).

The same flow deploys a Drupal codebase from your own CI. The recommended CI for Cloud Platform Drupal work is Pipelines (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 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).

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 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.

Was this page helpful?