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.
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- An
acquia_config.yamlwithenable-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_credentialstoken.
Prerequisites
Section titled “Prerequisites”- 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_SECRETContent 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).
-
Declare prebuilt artifacts in
Section titled “Declare prebuilt artifacts in acquia_config.yaml”acquia_config.yamlIn the root directory of the branch you deploy, set:
acquia_config.yaml enable-prebuilt-artifact: trueThe default is
false; without this flag the platform expects to build for you. The file must ship inside the artifact you push. -
Add the secrets to your CI system
Section titled “Add the secrets to your CI system”In GitHub Actions:
Settings > Secrets and variables > Actions. You need:Secret Value DEPLOY_SSH_KEYPrivate SSH key for pushing to the Acquia git repository ACQUIA_GIT_URLYour application’s Acquia git repository URL ACQUIA_CLOUD_API_KEY/ACQUIA_CLOUD_API_SECRETCloud Platform API credentials (get them here) ENVIRONMENT_IDID of the environment to deploy DRUPAL_SITE_URL/DRUPAL_CLIENT_ID/DRUPAL_CLIENT_SECRETYour 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_URLis not itself a secret, so a repository variable works for it; the ID and the secret are. -
Build the app and assemble the artifact
Section titled “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_modulesandacquia_config.yaml:Terminal window npm installnpm run buildrm -rf build-outputmkdir build-output# Next.js example - adjust the file list for your frameworkcp -R .next public package.json \next.config.ts node_modules \acquia_config.yaml build-output/Two things fail here, both on the
npm run buildline 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_SECRETrow in step 2 is for: put all three in the build step’senv:, as the workflow in step 5 does. With the three variables absent the build dies withFailed to collect page data for /, and withDRUPAL_SITE_URLset to a host it cannot reach it dies withError occurred prerendering page "/"andgetaddrinfo 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 --tswritesnext.config.ts, so that is the name in the list above; copynext.config.jsinstead andcpexits non-zero on the missing file, which fails the whole step under the-eshell GitHub Actions runs. Check your own repository and copy the file that is actually there. -
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-outputgit initgit config user.name "ci-bot"git remote add target "$ACQUIA_GIT_URL"mkdir -p ~/.sshecho "$DEPLOY_SSH_KEY" > ~/.ssh/id_rsachmod 600 ~/.ssh/id_rsaHOST=$(echo "$ACQUIA_GIT_URL" | \sed 's/.*@\([^:]*\):.*/\1/')ssh-keyscan -H "$HOST" >> ~/.ssh/known_hostsgit 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/*" -deletegit add -Agit commit -m "Build output from CI"git push target main-build -fFor 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--forceas 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), passinclude-hidden-files: true: v4 silently drops dotfiles otherwise, and a deploy missing its dotfiles fails in confusing ways. - Fetching the existing deploy branch first (
-
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_credentialsgrant, 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/switchA successful call returns
202 Acceptedwith 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.
--failmakescurlexit 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 thebranchfield:{"branch": "tags/v1.0.1"}.If you’d rather not hand-roll the token exchange,
acliwraps the same API call:Terminal window curl -sSL https://github.com/acquia/cli/releases/latest/download/acli.phar -o /usr/local/bin/aclichmod +x /usr/local/bin/acliacli auth:login --key="$ACQUIA_CLOUD_API_KEY" --secret="$ACQUIA_CLOUD_API_SECRET" --no-interaction # acli calls the same pair key and secretacli api:environments:code-switch "$ENVIRONMENT_ID" main-build --no-interactionThe branch is a positional argument;
--branch=fails withThe --branch option does not exist.Put together as a single GitHub Actions workflow:
.github/workflows/deploy-acquia.yml name: Deploy to Acquiaon:push:branches: [main]jobs:build-and-deploy:# Linux amd64, as Acquia requiresruns-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: actions/setup-node@v4with: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 installnpm run buildrm -rf build-output && mkdir build-outputcp -R .next public package.json \next.config.ts node_modules \acquia_config.yaml build-output/- name: Push artifact to Acquiaenv:DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}ACQUIA_GIT_URL: ${{ secrets.ACQUIA_GIT_URL }}run: |mkdir -p ~/.sshecho "$DEPLOY_SSH_KEY" > ~/.ssh/id_rsachmod 600 ~/.ssh/id_rsaHOST=$(echo "$ACQUIA_GIT_URL" | \sed 's/.*@\([^:]*\):.*/\1/')ssh-keyscan -H "$HOST" >> ~/.ssh/known_hostscd build-outputgit initgit 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/*" -deletegit add .MSG="Build output from CI ($GITHUB_SHA)"git commit -m "$MSG"git push target main-build -f- name: Trigger deploy via Cloud Platform APIenv: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 -
Verify the deployment
Section titled “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-buildis 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
Pipelinessection stays empty; that’s expected (nothing here runs through Pipelines), not a sign the deploy failed to register.
What just happened
Section titled “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 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
202and 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_dispatchtrigger with an environment choice so hotfixes can deploy any branch to any environment on demand. - Set a per-branch
concurrencygroup withcancel-in-progressdisabled 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
Section titled “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 (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:
- Build the artifact:
composer install --no-dev --optimize-autoloader --no-scripts, plus whatever compiles your theme assets.--no-devkeeps phpunit/phpstan/phpcs out of production; strip their config files and anynode_modulesfrom the artifact too. - Restore the Drupal scaffold files.
--no-scriptsskips the Composer scaffold, anddocroot/.gitignoreexcludes.htaccess,index.php,robots.txt, andautoload.phpfrom source control, so none of them are in your checkout. Without.htaccess, every URL except/404s. Copy them back fromdocroot/core/assets/scaffold/files/before packaging, and writedocroot/autoload.phpas a shim (<?php return require __DIR__ . '/../vendor/autoload.php';), never a copy ofvendor/autoload.php, whose relative paths only resolve from insidevendor/. - Name the deploy branch
pipelines-build-<branch>if the application previously deployed via Pipelines (note the pluralpipelines-). Matching the convention keeps the environment’s history continuous and existing Cloud Hooks wiring untouched. - Push and switch exactly as above; the code switch fires the application’s
post-code-deployhook, which is whereconfig:import/updb/cache:rebuildbelong (code-workflow guide).
When something goes wrong
Section titled “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 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
Section titled “Next steps”- Call the Cloud Platform API: create the Cloud Platform API client this flow authenticates with.
- CI/CD guide: the full landscape, promotion, and governance patterns this flow slots into.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)