# Deploy a Canvas Headless app

**Goal:** take a [Canvas Headless](/start-here/glossary/#canvas-headless) app, in any of its four frameworks, live on [Front End Hosting](/start-here/glossary/#front-end-hosting) – Advanced.

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

- Your app live on a Front End Hosting – Advanced [environment](/start-here/glossary/#environment), serving on the platform's port.
- A prebuilt [artifact](/start-here/glossary/#build-artifact) your CI can reproduce: built output, `node_modules`, and one config file.
- The one purge command that makes your first deploy actually visible.

## Prerequisites

- A Cloud Platform subscription with a Front End Hosting – Advanced [entitlement](/start-here/glossary/#entitlement), and an application with environments ([check what you have](/source-cms/deploy/choose-hosting/))
- An account whose team role includes repository access, with an **RSA** [SSH key](/start-here/glossary/#ssh-key) on your profile; the platform rejects ed25519 keys (never set one up? the [BYO CI guide's prerequisites](/source-cms/deploy/external-ci/#prerequisites) walk through it)
- A Canvas Headless app from the [Canvas Headless quickstart](/source-cms/get-content/quickstart/), with its [site](/start-here/glossary/#site) reachable
- [Node.js](https://nodejs.org/en/download) 22 or 24 locally (the versions the platform runs)

## Steps

<Steps>

1. ### Know what the platform runs

   Front End Hosting – Advanced does not build your app. It checks out the git ref you deploy, and runs `npm start` in it. Everything else follows from that:

   - The artifact you push is the **runnable app**: built output, `package.json` with a `start` script, and a committed `node_modules` directory. An artifact without `node_modules` fails to deploy, even when your framework's build output bundles its dependencies.
   - The platform injects `PORT=3000` and routes traffic to it. All four frameworks read it natively.
   - The readiness probe connects over **IPv6**, so the server must listen on all interfaces including IPv6 (the `::` bind). Next.js, Nuxt, and TanStack Start do this by default; Astro needs one config line (step 2). A server bound to `localhost` or an IPv4-only address runs fine while every deploy fails.
   - Keep the `start` script a **plain command** (`node server.js`, `next start`). Deploys where the script carried an environment-variable prefix (`HOST=:: node ...`) failed with the app producing no output; put configuration in the build or in environment variables instead.
   - One file at the artifact root tells the platform to skip its own build step:

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

2. ### Build the artifact from your scaffolded app

   The Canvas Headless templates build without a reachable Canvas site (nothing is fetched or baked in at build time), so CI can build the artifact anywhere. Run the build on Linux amd64 so native packages in `node_modules` match the platform. Each tab lists what the scaffolded template already has, and the pieces to add.

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

   <Tabs syncKey="framework">
     <TabItem label="Next.js">
       The template's `next start` already runs the built server and reads the platform's port. To make the checkout deployable:

       1. Remove `/.next/` and `/node_modules/` from `.gitignore`, build, and commit both (`.next/cache/` can stay ignored).
       2. Add `acquia_config.yaml` from step 1.

       ```json
       {
         "scripts": {
           "build": "next build",
           "start": "next start"
         }
       }
       ```

       The committed `node_modules` is what `next start` runs from; sharp is its only platform-specific package, matched automatically when the build runs on Linux amd64. To ship a far smaller artifact, Next's standalone mode also works on this platform (`output: 'standalone'` through the template's `withCanvas()`, `start: node server.js`, copy `public/` and `.next/static/` into the standalone directory after building).
     </TabItem>
     <TabItem label="Astro">
       The template already uses the Node adapter in standalone mode. Two additions make it deployable, and the first one is not optional:

       ```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: '::' },
       });
       ```

       The adapter binds `localhost` unless told otherwise, and its IPv4 forms (`host: true`, `HOST=0.0.0.0`) stay invisible to the platform's IPv6 readiness probe: the server boots and every deploy fails. `host: '::'` listens on all interfaces.

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

       Add the `start` script (the template has none), remove `/dist/` and `/node_modules/` from `.gitignore`, build, commit both (production dependencies only: `npm install --omit=dev`), and add `acquia_config.yaml`. Astro's build output does not bundle dependencies, so `node_modules` ships in full.
     </TabItem>
     <TabItem label="Nuxt">
       The template's `nuxt build` already emits a self-contained server in `.output/`. To make the checkout deployable:

       1. Add the `start` script below (the template has none).
       2. Remove `/.output/` and `/node_modules/` from `.gitignore`, build, and commit both (the platform requires a `node_modules` directory even though `.output` bundles everything the server imports).
       3. Add `acquia_config.yaml`.

       ```json
       {
         "scripts": {
           "build": "nuxt build",
           "start": "node .output/server/index.mjs"
         }
       }
       ```
     </TabItem>
     <TabItem label="TanStack Start">
       The template builds only a request handler; no production server exists until Nitro is added. Three changes, verified end to end:

       ```bash
       npm install -D nitro
       ```

       ```ts
       // vite.config.ts: add the import, and the plugin after tanstackStart()
       import { nitro } from 'nitro/vite';
       // … the template's existing imports stay

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

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

       With the plugin, `vite build` emits `.output/` with a self-contained server that reads the platform's port. Remove `/.output/` and `/node_modules/` from `.gitignore`, build, commit both, and add `acquia_config.yaml`.
     </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/canvas-headless-hosting.md for the full guide, then make my Canvas Headless Next.js app deployable as a prebuilt artifact: un-ignore and commit .next and node_modules, keep the plain next 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/canvas-headless-hosting.md for the full guide, then make my Canvas Headless Astro app deployable as a prebuilt artifact: add server: { host: '::' } to astro.config.mjs, add a plain node ./dist/server/entry.mjs start script, un-ignore and commit dist and a production node_modules, 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/canvas-headless-hosting.md for the full guide, then make my Canvas Headless Nuxt app deployable as a prebuilt artifact: add a plain node .output/server/index.mjs start script, un-ignore and commit .output and node_modules, 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/canvas-headless-hosting.md for the full guide, then make my Canvas Headless TanStack Start app deployable as a prebuilt artifact: add the nitro Vite plugin (node-server preset) after tanstackStart(), add a plain node .output/server/index.mjs start script, un-ignore and commit .output and node_modules, and add acquia_config.yaml with enable-prebuilt-artifact: true at the project root.
```
     </TabItem>
   </Tabs>

   The agent makes the same edits shown on the `Yourself` tab for your framework; review the diff before you commit.

   </TabItem>
   </Tabs>

3. ### Set the runtime variables before the first deploy

   On the environment page, add `CANVAS_SITE_URL` pointing at your live Canvas site. Variable names on Front End Hosting may use letters, numbers, and underscores, and must not start with a number, `AH_`, or `ACQUIA_`; the `CANVAS_*` names pass. The plain-Node servers do not read `.env` files, so the environment variable is the only control point in production.

   Order matters: the app fetches content on every request, so with no reachable Canvas site the homepage renders an error, the readiness probe fails, and **the deploy itself fails**. Set the variable first, then deploy. Make sure the site's front page resolves to a published Canvas page, so `/` renders 200 for the probe.

4. ### Push the artifact to your application's repository

   Your application's git URL is on its overview page in the Cloud Platform user interface. Push the artifact as its own branch:

   ```bash
   git checkout -b deploy-artifact
   git add -A -f && git commit -m "Build artifact"
   git push origin deploy-artifact
   ```

   If the push is refused, the two causes worth checking first: your key is not RSA, or your team role lacks repository access. Both produce the same `Permission denied (publickey)`.

5. ### Deploy the branch

   ```bash
   acli api:environments:code-switch <environment-id> "deploy-artifact" --task-wait
   ```

   The same action is `Switch code` on the environment page in the Cloud Platform user interface. The task takes 5 to 15 minutes. If it fails, the previous release keeps serving; nothing goes dark.

6. ### Purge the cache, or keep seeing the old site

   The platform fronts your environment with a cache, and a deploy does not purge it. On a new environment, the platform's placeholder page carries a one-year cache lifetime, so your homepage keeps showing it even after a green deploy:

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

   Purge after every deploy. Cached responses from the previous release persist the same way, error pages included.

7. ### Verify

   ```bash
   curl -s https://<your-environment-domain>/ | grep -o "<h1[^>]*>[^<]*</h1>"
   ```

   Your homepage's heading in that output means the app is live and serving from the platform's port.

</Steps>

## What just happened

The platform checked out your branch, found `enable-prebuilt-artifact: true`, skipped its build system, and ran `npm start` with `PORT=3000` injected. Your framework's production server bound all interfaces, read the port from the environment, and answered the platform's readiness probe on `/`. The purge step made the new release visible through the cache. Every later deploy is the same loop: build in CI, push the branch, switch, purge.

## When something goes wrong

**The homepage still shows "Welcome to Acquia Cloud"**: that is the platform's placeholder, cached with a one-year lifetime, not a failed deploy. It appears on every new environment (its copy talks about Drupal; on this product, ignore it). Run the purge from step 6.

**`Code switched` task fails while the site keeps working**: the previous release keeps serving through a failed deploy, and the environment's deployed ref still moves to the new branch. In order of likelihood, verified causes: 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.

**Changed a variable and the app has not picked it up**: variable changes apply through a platform task, and the app restarts with the new values about a minute after the task completes. If it never appears, check the name against the naming rule in step 3.

**`Permission denied (publickey)` on push**: the platform accepts RSA keys only, and a key on your profile is not enough; your team role must include repository access.

> Provisioning is automatic: signing the contract creates the Front End Hosting application with its environments (a verified new application came with Prod and Staging, Node.js 22 preselected). If your application is missing, [file a support ticket](https://acquia.my.site.com/s/contactsupport).

## Next steps

- [Front End Hosting](/source-cms/deploy/choose-hosting/): what the platform runs, and the entitlement check.
- [Deploy from your own CI/CD](/source-cms/deploy/external-ci/): the same artifact loop run by CI on every push, with multiple environments and a gated production deploy.
- [Canvas Headless quickstart](/source-cms/get-content/quickstart/): scaffold the app this page deploys.
