# Quickstart

**Goal:** take your app, in any of the four supported frameworks, from a GitHub repository to a live URL on [Front End Hosting](/start-here/glossary/#front-end-hosting) – Advanced, redeploying on every push.

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

- Your app live on a Front End Hosting – Advanced [environment](/start-here/glossary/#environment).
- A GitHub Actions workflow that rebuilds the [artifact](/start-here/glossary/#build-artifact) and redeploys it on every push.
- The purge command that makes every deploy visible through the platform's cache.

## Prerequisites

- Your app in a GitHub repository: a Canvas Headless app from the [Canvas Headless quickstart](/source-cms/get-content/quickstart/), the [First fetch](/source-cms/content-api/first-fetch/) app, or any app in one of the four frameworks below
- A Cloud Platform application with a Front End Hosting – Advanced [entitlement](/start-here/glossary/#entitlement) ([check yours](/source-cms/deploy/choose-hosting/))
- Cloud Platform API credentials (client ID + secret) from the [Cloud Platform API page](/cloud-platform/cli/platform-api/); these are account-level credentials, distinct from your site's `DRUPAL_*` or `CANVAS_*` pair
- An **RSA** [SSH key](/start-here/glossary/#ssh-key) pair whose public key is on your Acquia profile, on an account whose team role includes repository access (never set one up? the [BYO CI guide's prerequisites](/source-cms/deploy/external-ci/#prerequisites) walk through it), plus your application's Acquia git URL (on the application's overview page in the Cloud Platform user interface)
- [`jq`](https://jqlang.org/download/) locally, for one environment-ID lookup in step 3

## Steps

<Steps>

1. ### Shape the app into a deployable artifact

   The platform runs `npm start` on exactly what you push, with `PORT=3000` injected; it does not build your app. Each tab shows the framework's production shape: the config, the `start` script, and the artifact directory your CI will assemble in step 4.

   <Tabs syncKey="method">
   <TabItem label="Yourself">

   <Tabs syncKey="framework">
     <TabItem label="Next.js">
       ```js
       // next.config.js
       module.exports = { output: 'standalone' };
       ```

       ```json
       {
         "scripts": {
           "build": "next build && cp -r public .next/standalone/public && cp -r .next/static .next/standalone/.next/static",
           "start": "node server.js"
         }
       }
       ```

       The artifact is the `.next/standalone` directory: `server.js`, a traced `node_modules`, your `public/` and static assets copied in. `next start` with a full committed `node_modules` also works; standalone is far smaller.
     </TabItem>
     <TabItem label="Astro">
       ```js
       // astro.config.mjs
       import { defineConfig } from 'astro/config';
       import node from '@astrojs/node';

       export default defineConfig({
         output: 'server',
         adapter: node({ mode: 'standalone' }),
         server: { host: '::' },
       });
       ```

       ```json
       {
         "scripts": {
           "build": "astro build",
           "start": "node ./dist/server/entry.mjs"
         }
       }
       ```

       `server: { host: '::' }` is required: the adapter's default binds `localhost`, which the platform's readiness probe (IPv6) cannot reach, and every deploy fails while the server runs. The artifact is `dist/` plus a production `node_modules` (`npm install --omit=dev`); Astro's output does not bundle dependencies.
     </TabItem>
     <TabItem label="Nuxt">
       ```ts
       // nuxt.config.ts
       export default defineNuxtConfig({
         nitro: { preset: 'node-server' },
       });
       ```

       ```json
       {
         "scripts": {
           "build": "nuxt build",
           "start": "node .output/server/index.mjs"
         }
       }
       ```

       The artifact is `.output/` plus `package.json`, plus a committed `node_modules`: the platform requires the directory even though `.output` bundles everything the server imports.
     </TabItem>
     <TabItem label="TanStack Start">
       ```ts
       // vite.config.ts: add the import, and the plugin after tanstackStart()
       import { nitro } from 'nitro/vite';
       // … the existing imports stay

       export default defineConfig({
         plugins: [tanstackStart(), nitro({ config: { preset: 'node-server' } }), viteReact()],
       });
       ```

       ```json
       {
         "scripts": {
           "build": "vite build",
           "start": "node .output/server/index.mjs"
         }
       }
       ```

       Without the Nitro plugin, `vite build` emits only a request handler with no production server. With it, the artifact is `.output/` plus `package.json` plus a committed `node_modules`, as with Nuxt.
     </TabItem>
   </Tabs>

   </TabItem>

   <TabItem label="With an AI agent">

   <Tabs syncKey="framework">
     <TabItem label="Next.js">
```text
Read https://dev.acquia.com/source-cms/deploy/quickstart.md for the full guide, then prepare my Next.js app for a prebuilt-artifact deploy: set output: 'standalone' in next.config.js, add a next build build script and a plain node server.js start script, and add acquia_config.yaml with enable-prebuilt-artifact: true at the project root.
```
     </TabItem>
     <TabItem label="Astro">
```text
Read https://dev.acquia.com/source-cms/deploy/quickstart.md for the full guide, then prepare my Astro app for a prebuilt-artifact deploy: set output: 'server' with the @astrojs/node adapter in standalone mode and server: { host: '::' } in astro.config.mjs, add a plain node ./dist/server/entry.mjs start script, and add acquia_config.yaml with enable-prebuilt-artifact: true at the project root.
```
     </TabItem>
     <TabItem label="Nuxt">
```text
Read https://dev.acquia.com/source-cms/deploy/quickstart.md for the full guide, then prepare my Nuxt app for a prebuilt-artifact deploy: pin the node-server Nitro preset in nuxt.config.ts, add a plain node .output/server/index.mjs start script, and add acquia_config.yaml with enable-prebuilt-artifact: true at the project root.
```
     </TabItem>
     <TabItem label="TanStack Start">
```text
Read https://dev.acquia.com/source-cms/deploy/quickstart.md for the full guide, then prepare my TanStack Start app for a prebuilt-artifact deploy: add the nitro Vite plugin (node-server preset) after tanstackStart() in vite.config.ts, add a plain node .output/server/index.mjs start script, and add acquia_config.yaml with enable-prebuilt-artifact: true at the project root.
```
     </TabItem>
   </Tabs>

   The agent makes the same config, `package.json`, and `acquia_config.yaml` edits shown on the `Yourself` tab; review the diff before you commit.

   </TabItem>
   </Tabs>

   Whatever the framework, two more rules from the platform: keep `start` a plain command (a `VAR=x node ...` prefix never executes), and add one file at the artifact root so the platform skips its own build step:

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

2. ### Set the secrets

   In GitHub, under `Settings > Secrets and variables > Actions`, add the deploy secrets: `DEPLOY_SSH_KEY` (the private SSH key), `ACQUIA_GIT_URL`, `CLIENT_ID` / `CLIENT_SECRET` (the Cloud Platform API pair), and `ENVIRONMENT_ID`. The [BYO CI guide's secrets table](/source-cms/deploy/external-ci/) defines each one.

   If your app prerenders content at build time, add its site credentials too, so `next build` can fetch: `DRUPAL_SITE_URL`, `DRUPAL_CLIENT_ID`, `DRUPAL_CLIENT_SECRET`. The First fetch app does prerender; the Canvas Headless templates fetch at request time instead.

3. ### Set the runtime variables

   Add what your app reads at request time to the Cloud Platform environment, one command per variable:

   ```bash
   acli api:environments:variable-create myapp.prod \
     --name='CANVAS_SITE_URL' --value='https://your-site.example.com' --task-wait
   ```

   Set `CANVAS_SITE_URL` for a Canvas Headless app, or the `DRUPAL_*` trio for a JSON:API app. Names may use letters, numbers, and underscores, and must not start with a number, `AH_`, or `ACQUIA_`.

   Do this before the first deploy: an app whose content backend is unreachable renders errors on `/`, and the readiness probe then fails the deploy itself.

   Find the environment ID for the workflow's secret:

   ```bash
   acli api:applications:environment-list myapp \
     | jq '.[] | {name, id}'
   ```

   ```text
   {
     "name": "dev",
     "id": "123456-a1b2c3d4-5678-90ab-cdef-1234567890ab"
   }
   ```

4. ### Add the deploy workflow and push

   One workflow builds the app, assembles the artifact, pushes it to your application's Acquia git remote, and triggers the deploy through the Cloud Platform API. The `Assemble artifact` step is the framework-specific line from step 1:

   ```yaml
   # .github/workflows/deploy-acquia.yml
   name: Deploy to Acquia
   on:
     push:
       branches: [main]
   jobs:
     build-and-deploy:
       runs-on: ubuntu-latest   # Linux amd64, as Acquia requires
       steps:
         - uses: actions/checkout@v4
         - uses: actions/setup-node@v4
           with:
             node-version: 22
         - name: Build
           env:
             DRUPAL_SITE_URL: ${{ secrets.DRUPAL_SITE_URL }}
             DRUPAL_CLIENT_ID: ${{ secrets.DRUPAL_CLIENT_ID }}
             DRUPAL_CLIENT_SECRET: ${{ secrets.DRUPAL_CLIENT_SECRET }}
           run: |
             npm ci
             npm run build
         - name: Assemble artifact
           run: |
             rm -rf build-output && mkdir build-output
             # Next.js (standalone): cp -R .next/standalone/. build-output/
             # Astro:               cp -R dist node_modules package.json build-output/
             # Nuxt:                cp -R .output node_modules package.json build-output/
             # TanStack Start:      cp -R .output node_modules package.json build-output/
             cp -R .next/standalone/. build-output/
             cp 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
             ssh-keyscan -H "$(echo "$ACQUIA_GIT_URL" | sed 's/.*@\([^:]*\):.*/\1/')" >> ~/.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
             git add -A -f
             git commit -m "Build output from CI ($GITHUB_SHA)"
             git push target main-build -f
         - name: Trigger deploy via Cloud Platform API
           env:
             CLIENT_ID: ${{ secrets.CLIENT_ID }}
             CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
             ENVIRONMENT_ID: ${{ secrets.ENVIRONMENT_ID }}
           run: |
             ACCESS_TOKEN=$(curl --silent -L --request POST \
               --url "https://accounts.acquia.com/api/auth/oauth/token" \
               --header "content-type: application/x-www-form-urlencoded" \
               --data grant_type=client_credentials \
               --data client_id="${CLIENT_ID}" \
               --data-urlencode client_secret="${CLIENT_SECRET}" | jq -r .access_token)
             curl --silent --fail -X POST \
               "https://cloud.acquia.com/api/environments/${ENVIRONMENT_ID}/code/actions/switch" \
               -H "Authorization: Bearer $ACCESS_TOKEN" \
               -H "Content-Type: application/json" \
               -H "Accept: application/hal+json" \
               -d '{"branch": "main-build"}'
   ```

   Commit and push:

   ```bash
   git add package.json acquia_config.yaml .github/workflows/deploy-acquia.yml
   git commit -m "Configure Front End Hosting deploy"
   git push origin main
   ```

   Watch the run under your repository's `Actions` tab. The green workflow run is not the end: the switch call creates a `Code switched` task on the environment, and the code goes live when that task completes, 5 to 15 minutes later. Open the target environment in the Cloud UI and watch the task complete before verifying. If the task fails, the previous release keeps serving; nothing goes dark.

5. ### Purge the cache and verify

   The platform's cache survives deploys, and a new environment's placeholder page is cached with a one-year lifetime, so purge after every deploy:

   ```bash
   acli api:environments:domain-clear-caches <environment-id> <your-environment-domain>
   ```

   Then request the environment's default URL (on the environment page in the Cloud UI):

   ```bash
   curl -s https://myappdev.prod.acquia-sites.com/ \
     | grep -o "<h1[^>]*>[^<]*</h1>"
   ```

   Your homepage's heading in that output means your app is live and serving.

</Steps>

## What just happened

Your CI did the whole deploy in two moves the platform understands. The *artifact push* is plain git: because `enable-prebuilt-artifact: true` ships inside the artifact, the platform serves what you pushed instead of building 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`. At request time the platform runs `npm start` with `PORT=3000` injected, which your framework's server reads from the environment. Every push to `main` repeats the run; the purge makes each release visible.

## When something goes wrong

**The homepage still shows "Welcome to Acquia Cloud"**: the platform placeholder, cached for up to a year, not a failed deploy. It appears on every new environment (its copy talks about Drupal; ignore that). Purge per step 5.

**`Code switched` task fails while the previous release keeps serving**: the verified causes, in order of likelihood: the server binds `localhost` or IPv4 only (Astro without `server: { host: '::' }`); the artifact has no committed `node_modules`; the `start` script carries an environment-variable prefix or does not run the built server; `acquia_config.yaml` is missing from the artifact root; the app's content backend is unreachable so `/` errors and the probe fails.

**`Permission denied (publickey).` in the Push artifact step**: the key is not RSA (the platform rejects ed25519), the `DEPLOY_SSH_KEY` secret doesn't match a key on your Acquia profile, or your team role lacks repository access.

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

**A variable change hasn't reached the app**: variable updates apply through a platform task, and the app restarts with the new values about a minute after the task completes.

## Next steps

- [Deploy a Canvas Headless app](/source-cms/deploy/canvas-headless-hosting/): the same artifact shapes deployed by hand, with the template-specific details per framework.
- [Deploying a headless frontend](/source-cms/deploy/guide/): production secrets, ISR mechanics, autoscaling, and the platform's constraints.
- [Deploy from your own CI/CD](/source-cms/deploy/external-ci/): this same flow with multiple environments and a gated production deploy.
