AWS Amplify · 2026-10-02 · Amplify arc

AWS Amplify for MCP Servers: Hosting Pipeline, Gen 2 Code-First IaC, and Configuration Management

Amplify solves three distinct problems for MCP server teams: Amplify Hosting gives you a CI/CD pipeline from Git push to deployed CloudFront distribution in minutes; Amplify Gen 2 gives you a TypeScript-native CDK abstraction that collapses the gap between "Amplify magic" and "real infrastructure code"; and Amplify's configuration model gives you a two-level environment variable hierarchy, a secrets pattern backed by SSM Parameter Store, and automatic ACM certificate management for custom domains. The five pillars — Hosting and build pipeline, custom domain and ACM, Gen 2 CDK patterns, PR preview environments, and environment variables and secrets — each has its own narrow contract that, when violated, fails silently or catastrophically. The wrong platform flag (WEB instead of WEB_COMPUTE) deploys a blank Next.js site with no error. A missing nvm use 20 line in the preBuild phase causes native module compile failures against the wrong Node.js version. An update-branch call that omits previously-set variables silently deletes them. The ACM certificate for CloudFront must always be in us-east-1, regardless of your Amplify app's region. This guide synthesizes all five topics into three structural patterns: the Hosting deployment pipeline from repo connection to custom domain, the Gen 2 code-first IaC workflow from schema definition to production deploy, and the configuration and secrets management model from variable scoping to SSM integration.

TL;DR

Pattern 1 — Amplify Hosting deployment pipeline

App creation and the platform flag

An Amplify App maps to a single Git repository. The most consequential configuration decision happens at creation time: the --platform flag. WEB serves static files — HTML, CSS, JavaScript, images — from S3 via CloudFront. WEB_COMPUTE provisions Lambda@Edge functions behind CloudFront to handle server-side rendering. If you use Next.js with the App Router or any server components, you need WEB_COMPUTE. Setting WEB for a Next.js SSR project causes Amplify to deploy only the static assets — server-rendered pages return blank HTML because there's no Lambda to render them.

# Create a static site (React/Vite/Angular)
aws amplify create-app \
  --name mcp-dashboard \
  --repository https://github.com/your-org/mcp-dashboard \
  --access-token $GITHUB_PAT \
  --platform WEB

# Create a Next.js SSR app (App Router or Pages Router with getServerSideProps)
aws amplify create-app \
  --name mcp-admin \
  --repository https://github.com/your-org/mcp-admin \
  --access-token $GITHUB_PAT \
  --platform WEB_COMPUTE

# Add a production branch with auto-build enabled
aws amplify create-branch \
  --app-id $APP_ID \
  --branch-name main \
  --stage PRODUCTION \
  --enable-auto-build \
  --environment-variables \
    REACT_APP_API_URL=https://api.alivemcp.com,REACT_APP_ENV=production

Amplify uses the Amplify GitHub App for repository connections — not OAuth tokens. The GitHub App installation must have checks:write and statuses:write permissions for PR status checks to appear. If you connected via a personal access token, status checks won't post to PRs. Reconnect via the GitHub App in the Amplify console to fix this.

amplify.yml: the four build phases

Amplify reads amplify.yml from the repository root to control the build. The file has four phases: preBuild, build, postBuild, and test. The artifacts.baseDirectory tells Amplify where the deployable output lives. Getting this wrong is a common failure mode — the build succeeds but Amplify deploys an empty directory, producing 403s from CloudFront.

# amplify.yml — React + Vite (static, platform=WEB)
version: 1
frontend:
  phases:
    preBuild:
      commands:
        - nvm use 20           # override default Node 16 — first command, every time
        - npm ci               # lockfile install
    build:
      commands:
        - npm run build
  artifacts:
    baseDirectory: dist        # Vite output; CRA uses build/; Angular uses dist/{project-name}/
    files:
      - '**/*'
  cache:
    paths:
      - node_modules/**/*      # saves ~30s on subsequent builds
---
# amplify.yml — Next.js App Router (SSR, platform=WEB_COMPUTE)
version: 1
frontend:
  phases:
    preBuild:
      commands:
        - nvm use 20
        - npm ci
    build:
      commands:
        - npm run build
  artifacts:
    baseDirectory: .next       # WEB_COMPUTE requires .next — NOT out/
    files:
      - '**/*'
  cache:
    paths:
      - .next/cache/**/*
      - node_modules/**/*

The Node.js version pitfall: Amplify's build container is Amazon Linux 2 and defaults to Node 16. If your project requires Node 18 or 20 (common for ESM packages, native modules, or newer Next.js versions), nvm use 20 must be the first command in preBuild. Omitting it causes hard-to-diagnose failures: ERR_UNSUPPORTED_ESM_URL_SCHEME, native module compile errors, or Unsupported engine warnings that surface as runtime crashes after deployment.

Monorepo configuration with appRoot

If your MCP server repository is a monorepo (Turborepo, Nx, npm workspaces), appRoot in amplify.yml tells Amplify which subdirectory contains the frontend. All build phases run with appRoot as the working directory, and artifacts.baseDirectory is relative to appRoot.

# amplify.yml — monorepo: backend at /packages/api, frontend at /apps/dashboard
version: 1
applications:
  - frontend:
      phases:
        preBuild:
          commands:
            - nvm use 20
            - npm ci --workspace=apps/dashboard
        build:
          commands:
            - npm run build --workspace=apps/dashboard
      artifacts:
        baseDirectory: apps/dashboard/dist
        files:
          - '**/*'
      cache:
        paths:
          - node_modules/**/*
          - apps/dashboard/node_modules/**/*
    appRoot: apps/dashboard

Use npm ci --workspace=apps/dashboard rather than bare npm ci to avoid downloading packages from unrelated workspaces. Caching node_modules/**/* at the root avoids re-downloading the entire dependency graph on every build — for a monorepo with 500+ packages this difference is 3–5 minutes per build.

SPA routing rewrites

React, Vue, and Angular apps handle routing client-side via the History API. When a user navigates directly to /settings/integrations, Amplify's CloudFront distribution looks for a file at that path — which doesn't exist — and returns 403 (AccessDenied from S3). The fix is a rewrite rule that maps all unmatched paths to /index.html with status 200. Use 200 (rewrite, URL unchanged) not 301/302 (redirect, URL changes) — redirects break deep links.

# amplify.yml — SPA rewrite rule
customRules:
  - source: '</^[^.]+$|\.(?!(css|gif|ico|jpg|js|png|txt|svg|woff|ttf|map|json)$)([^.]+$)/>'
    target: /index.html
    status: '200'
  # Optional: proxy /api/* to your MCP server backend during development
  - source: /api/<path>
    target: https://api.alivemcp.com/<path>
    status: '200'

The regex matches any path that either has no file extension or has an extension not in the whitelist of static asset types. Paths like /settings/integrations (no extension) are rewritten to /index.html and served with 200. Paths like /assets/main.js or /favicon.ico pass through to the actual S3 object.

PR preview environments

Amplify Hosting creates a full preview deployment for every pull request targeting a tracked branch. The preview URL follows the pattern https://pr-{pr-number}.{target-branch}.{app-id}.amplifyapp.com — note it uses the target branch name, not the source branch. A PR from feat/dark-mode targeting main gets https://pr-47.main.d1abc23def456.amplifyapp.com. The preview branch inside Amplify is named pr-47, not feat/dark-mode — this matters when you read build logs or query the branch API.

# Enable PR previews on the main branch
aws amplify update-branch \
  --app-id $APP_ID \
  --branch-name main \
  --enable-pull-request-preview \
  --environment-variables \
    REACT_APP_API_URL=https://staging-api.alivemcp.com,\
    REACT_APP_ENV=preview

# For Gen 2 backends: provision an isolated backend stack per PR
aws amplify update-branch \
  --app-id $APP_ID \
  --branch-name main \
  --enable-pull-request-preview \
  --pull-request-environment-name staging

# AWS_BRANCH in the build container is the preview branch name: "pr-47"
# Use it to detect preview vs production:
# if [ "$AWS_BRANCH" = "main" ]; then
#   export REACT_APP_API_URL=https://api.alivemcp.com
# else
#   export REACT_APP_API_URL=https://staging-api.alivemcp.com
# fi

PR previews inherit environment variables from the base branch by default. If your base branch points to the production API, previews will mutate production data — always set preview branches to use the staging API endpoint. The most reliable approach is to use $AWS_BRANCH inside amplify.yml to conditionally set the API URL at build time, rather than managing per-branch CLI variable updates. Amplify deletes preview deployments when the PR is merged or closed. If the Git provider's webhook delivery fails, stale pr-* branches can accumulate — run a weekly cleanup using aws amplify list-branches filtered on pr- branch names.

Pattern 2 — Gen 2 code-first IaC

The amplify/ directory and backend.ts

Amplify Gen 2 (released 2024) replaces the YAML-file-based Amplify CLI with a TypeScript-native approach. Your backend infrastructure lives in an amplify/ directory as typed CDK constructs. Each capability has its own subdirectory with a resource.ts file. The amplify/backend.ts entry point imports and composes them all. The critical Gen 2 feature is the escape hatch: after defineBackend() composes all resources, you can access the underlying CDK constructs via backend.*.resources and make any configuration the Amplify abstraction doesn't expose.

// amplify/backend.ts
import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
import { data } from './data/resource';
import { mcpToolDispatch } from './functions/mcpToolDispatch/resource';

const backend = defineBackend({ auth, data, mcpToolDispatch });

// Escape hatch: access raw CDK constructs
const { cfnTables } = backend.data.resources;
const sessionTable = cfnTables['McpSession'];

// Inject table name into the Lambda and grant access
backend.mcpToolDispatch.resources.lambda.addEnvironment(
  'SESSION_TABLE_NAME',
  sessionTable.ref
);
backend.mcpToolDispatch.resources.lambda.addToRolePolicy(
  new aws_iam.PolicyStatement({
    actions: ['dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:UpdateItem'],
    resources: [`arn:aws:dynamodb:*:*:table/${sessionTable.ref}`]
  })
);

The cfnTables escape hatch exposes CfnTable constructs for every model defined in defineData. From there you can set DynamoDB TTL attributes, configure DynamoDB Streams ARNs for event-driven processing, or adjust billing mode — configurations that defineData's DSL doesn't expose directly. Similarly, backend.auth.resources.userPool gives you the underlying Cognito User Pool L2 construct for settings like advanced password policies or custom email templates.

defineData: GraphQL schema DSL and DynamoDB tables

defineData provisions an AppSync GraphQL API backed by DynamoDB. You write your schema using Amplify's TypeScript DSL — each a.model() becomes a DynamoDB table and a set of CRUD resolvers. The important constraint: defineData always creates a DynamoDB-backed AppSync API. If you want DynamoDB without GraphQL, use defineBackend with a raw CDK aws_dynamodb.Table — the defineData abstraction doesn't support bypassing AppSync.

// amplify/data/resource.ts
import { defineData, a } from '@aws-amplify/backend';

const schema = a.schema({
  McpSession: a.model({
    sessionId: a.id().required(),
    userId: a.string().required(),
    teamId: a.string().required(),
    status: a.enum(['active', 'expired', 'revoked']),
    expiresAt: a.integer()
  })
  .authorization((allow) => [
    allow.owner(),                                      // userId can CRUD their own sessions
    allow.group('admins').to(['read', 'update'])        // admins can read + update all
  ]),

  McpToolCall: a.model({
    callId: a.id().required(),
    sessionId: a.string().required(),
    toolName: a.string().required(),
    status: a.enum(['pending', 'running', 'completed', 'failed']),
    durationMs: a.integer(),
    errorMessage: a.string()
  })
  .secondaryIndexes((index) => [
    index('sessionId')
      .sortKeys(['createdAt'])
      .queryField('listToolCallsBySession')   // creates AppSync query + GSI
  ])
  .authorization((allow) => [
    allow.owner().identityClaim('sub'),
    allow.authenticated().to(['read'])
  ])
});

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: 'userPool',
    apiKeyAuthorizationMode: { expiresInDays: 30 }
  }
});

The .secondaryIndexes() DSL creates a DynamoDB GSI and a corresponding AppSync query resolver (listToolCallsBySession in the example). If you use multiple sort keys — .sortKeys(['field1', 'field2']) — Amplify concatenates them with # separators in the actual DynamoDB sort key attribute. Query expressions must use the concatenated format: "field1#field2", not "field1" and "field2" separately. Queries that don't account for this return wrong or empty results silently.

defineAuth: Cognito User Pool and triggers

defineAuth provisions a Cognito User Pool for authentication (email/password, social sign-in, MFA) and optionally a Cognito Identity Pool for direct AWS service access from the browser. Cognito triggers — Lambda functions that fire during auth events — are defined inline in defineAuth. Triggers defined this way are automatically wired up with the correct Cognito invocation permission; manually created Lambda functions not registered via defineAuth don't receive Cognito invocations even if you add them in the Cognito console after the fact.

// amplify/auth/resource.ts
import { defineAuth, defineFunction } from '@aws-amplify/backend';

export const auth = defineAuth({
  loginWith: {
    email: true
  },
  userAttributes: {
    email: { required: true, mutable: false },
    'custom:teamId': { dataType: 'String', mutable: true }
  },
  groups: ['admins', 'members'],
  triggers: {
    preSignUp: defineFunction({
      entry: './functions/pre-sign-up/handler.ts'
    }),
    postConfirmation: defineFunction({
      entry: './functions/post-confirmation/handler.ts'
    })
  },
  multifactor: {
    mode: 'OPTIONAL',
    totp: true
  }
});

The preSignUp trigger is the right place to block sign-ups from non-allowlisted email domains without creating partial user records. Return { ...event, response: { autoConfirmUser: false } } and throw an error to reject the sign-up. The postConfirmation trigger fires after a user completes email verification — use it to create initial records in DynamoDB (e.g., a UserProfile record with the Cognito sub as the owner).

defineFunction and Lambda bundling

defineFunction creates a Lambda function from a TypeScript handler file. Amplify uses esbuild to bundle the handler and all its imports into a single JavaScript file — no zip file creation or node_modules copying required. The bundling happens during npx ampx sandbox or npx ampx pipeline-deploy. External modules that require native binaries (e.g., sharp for image processing, bcrypt) must be excluded from esbuild bundling and deployed as Lambda Layers.

// amplify/functions/mcpToolDispatch/resource.ts
import { defineFunction } from '@aws-amplify/backend';

export const mcpToolDispatch = defineFunction({
  name: 'mcpToolDispatch',
  entry: './handler.ts',
  timeoutSeconds: 30,
  memoryMB: 512,
  environment: {
    LOG_LEVEL: 'INFO'
    // Table names and ARNs injected via backend.ts escape hatch
  },
  runtime: 20   // Node.js 20.x
});

// amplify/functions/mcpToolDispatch/handler.ts
import type { Handler } from 'aws-lambda';

export const handler: Handler = async (event) => {
  const { toolName, sessionId, input } = event;
  // Tool dispatch logic
  return { status: 'completed', result: { /* output */ } };
};

Dynamic values — DynamoDB table names, SQS queue URLs, secret ARNs — should not be hardcoded in defineFunction's environment block. Instead, inject them in backend.ts after all resources are defined, using backend.myFunction.resources.lambda.addEnvironment('TABLE_NAME', backend.data.resources.cfnTables['McpSession'].ref). This ensures the values are always in sync with the actual deployed resource names, even when Amplify auto-generates them with hash suffixes.

Sandbox and pipeline-deploy workflows

Amplify Gen 2 has two deployment modes. The sandbox creates an isolated CDK stack named after your IAM username — each developer gets their own copy of the backend, and file saves trigger incremental CDK diffs and deployments in ~30 seconds. Pipeline deploy is the CI/CD variant: it reads the AWS_BRANCH and AWS_APP_ID environment variables that Amplify Hosting injects automatically, deploys to the corresponding branch environment, and outputs amplify_outputs.json with the deployed resource endpoints.

# Developer workflow: start the sandbox
npx ampx sandbox
# Creates: amplify-{appName}-{iamUsername}-sandbox-{hash}
# Watch mode: file change → CDK diff → incremental deploy (~30s)

# Clean up the sandbox when done (saves cost — sandbox resources are not free)
npx ampx sandbox delete

# CI/CD: amplify.yml with Gen 2 backend deploy
version: 1
backend:
  phases:
    build:
      commands:
        - npm ci
        - npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID
frontend:
  phases:
    preBuild:
      commands:
        - nvm use 20
        - npm ci
    build:
      commands:
        - npm run build   # reads amplify_outputs.json output by pipeline-deploy
  artifacts:
    baseDirectory: dist
    files:
      - '**/*'

The amplify_outputs.json file is the contract between the backend deploy phase and the frontend build. It contains the AppSync API URL, Cognito User Pool ID, Cognito Identity Pool ID, and all other endpoints that the Amplify frontend client library reads at startup. The backend phase must complete before the frontend phase — in amplify.yml, the backend.phases block runs first, then frontend.phases. If you accidentally put pipeline-deploy in the frontend phase, the frontend build fails with "cannot find amplify_outputs.json" because the file hasn't been written yet.

Sandbox stacks accumulate charges when left running — each developer's sandbox includes a Cognito User Pool, DynamoDB tables, and potentially AppSync APIs. Always run npx ampx sandbox delete after finishing a development session. Set a CloudWatch billing alarm filtered on sandbox stack name patterns (amplify-*-sandbox-*) to catch forgotten sandboxes.

Pattern 3 — Configuration and secrets management

Two-level variable scoping and the replacement trap

Amplify's environment variable model has two levels: app-level variables apply to every branch (including auto-created branches and PR previews), and branch-level variables override app-level values for a specific branch. Variables are merged at build time with branch-level winning. The critical operational trap: aws amplify update-branch --environment-variables is a complete replacement of branch-level variables, not a merge. If you call it with one new variable, all previously set branch-level variables are silently deleted.

# CORRECT: set all branch-level variables in one call
aws amplify update-branch \
  --app-id $APP_ID \
  --branch-name main \
  --environment-variables \
    REACT_APP_API_URL=https://api.alivemcp.com,\
    REACT_APP_ENV=production,\
    REACT_APP_STRIPE_KEY=pk_live_...,\
    BUILD_VERSION=1.2.0

# WRONG: this silently deletes all previous branch-level vars except REACT_APP_ENV
aws amplify update-branch \
  --app-id $APP_ID \
  --branch-name main \
  --environment-variables REACT_APP_ENV=production

# Variable change does NOT trigger a redeploy — always follow with:
aws amplify start-job \
  --app-id $APP_ID \
  --branch-name main \
  --job-type RELEASE

The safest workflow for managing branch variables via CLI is to always fetch the current set first with aws amplify get-branch --query 'branch.environmentVariables', merge the changes locally, then call update-branch with the full merged set. The Amplify console UI is safer for individual variable edits because it performs a read-modify-write rather than a full replacement.

Build-time vs runtime variable access in Next.js

Next.js has three categories of environment variables, and which category a variable falls into determines where it's available. Getting this wrong is the most common source of "undefined environment variable in production" bugs with Amplify SSR deployments.

# Category 1: NEXT_PUBLIC_ prefix — build-time embedded, client-visible
# Set in Amplify BEFORE the build runs (value is baked into the JS bundle)
NEXT_PUBLIC_API_URL=https://api.alivemcp.com      # available in browser AND server
NEXT_PUBLIC_POSTHOG_KEY=phc_abc123                 # embedded as a literal in the bundle

# Category 2: No prefix — runtime-only, server-side only
# Available in API routes, Server Actions, server components — NOT in browser
DATABASE_URL=postgres://...                        # set at APP level, not branch level
INTERNAL_API_SECRET=secret-value

# In a Next.js Server Action or API route:
export async function GET() {
  const db = connect(process.env.DATABASE_URL);    // works server-side
  // process.env.DATABASE_URL is undefined in browser components — correct
}

# Category 3: Amplify-injected build vars (available in amplify.yml and next.config.ts)
AWS_APP_ID, AWS_BRANCH, AWS_REGION, AWS_JOB_ID, AWS_COMMIT_ID

The most important nuance: no-prefix runtime variables must be set at the app level, not the branch level. Amplify populates the Lambda@Edge function environment from app-level variables — branch-level variables without NEXT_PUBLIC_ do not flow to the Lambda@Edge runtime. This means if you set DATABASE_URL at the branch level expecting it to be available in your Next.js API routes, it will be undefined at runtime even though it appears correct in the Amplify console. Set all server-side runtime secrets at the app level, then use app-level overrides for branch-specific values if needed.

SSM Parameter Store for secrets

Amplify's built-in "secret" variable type encrypts values at rest and masks them in build logs, but the values are still stored in Amplify's own infrastructure. For truly sensitive credentials — database connection strings, private API keys, signing certificates — the recommended pattern is to store them in AWS SSM Parameter Store as SecureString values and fetch them during the build's preBuild phase. This keeps secrets entirely outside Amplify's control plane.

# Store secrets in SSM (one-time setup, done outside Amplify)
aws ssm put-parameter \
  --name /alivemcp/prod/db-url \
  --value "postgres://user:pass@db.alivemcp.com/mcpdb" \
  --type SecureString \
  --key-id alias/aws/ssm

aws ssm put-parameter \
  --name /alivemcp/prod/stripe-secret-key \
  --value "sk_live_..." \
  --type SecureString

# amplify.yml — fetch secrets in preBuild, export for build phase
version: 1
frontend:
  phases:
    preBuild:
      commands:
        - nvm use 20
        - npm ci
        - |
          export DATABASE_URL=$(aws ssm get-parameter \
            --name /alivemcp/prod/db-url \
            --with-decryption \
            --query Parameter.Value \
            --output text)
          export STRIPE_SECRET_KEY=$(aws ssm get-parameter \
            --name /alivemcp/prod/stripe-secret-key \
            --with-decryption \
            --query Parameter.Value \
            --output text)
    build:
      commands:
        - npm run build    # DATABASE_URL and STRIPE_SECRET_KEY available here
  artifacts:
    baseDirectory: dist
    files:
      - '**/*'

The Amplify build container runs with a service IAM role. To allow the build to call ssm:GetParameter, attach an IAM policy to the Amplify service role (under App settings → General → Service role) with ssm:GetParameter and, for CMK-encrypted SecureString values, kms:Decrypt. Scope the policy to the specific parameter ARNs or a path prefix like arn:aws:ssm:*:*:parameter/alivemcp/* rather than granting access to all SSM parameters in the account.

Custom domain setup and ACM in us-east-1

When you add a custom domain to an Amplify app, Amplify requests an ACM certificate in us-east-1 — always, regardless of your app's region. This is because Amplify uses CloudFront, and CloudFront requires ACM certificates to be in us-east-1. If you're troubleshooting a certificate issue, all aws acm commands must include --region us-east-1; otherwise you'll see an empty list even if the certificate exists.

# Route 53 domain: Amplify handles all DNS automatically
aws amplify create-domain-association \
  --app-id $APP_ID \
  --domain-name dashboard.alivemcp.com \
  --sub-domains \
    prefix=www,branchName=main \
    prefix='',branchName=main      # zone apex — Amplify creates Route 53 ALIAS record

# External DNS (Cloudflare, Namecheap): create two CNAMEs manually
# Step 1: create the association (Amplify can't touch external DNS)
aws amplify create-domain-association \
  --app-id $APP_ID \
  --domain-name dashboard.alivemcp.com \
  --sub-domains prefix=www,branchName=main

# Step 2: get the two CNAME records to create at your DNS provider
aws amplify get-domain-association \
  --app-id $APP_ID \
  --domain-name dashboard.alivemcp.com
# Returns:
#   certificateVerificationDNSRecord: "_abc123.domain.com CNAME _def.acm-validations.aws"
#   subDomains[0].dnsRecord: "www.domain.com CNAME {appId}.cloudfront.net"

# IMPORTANT: the ACM validation CNAME must remain permanently
# Deleting it after initial setup breaks the 13-month auto-renewal cycle

# Troubleshoot certificate issues — always add --region us-east-1
aws acm list-certificates --region us-east-1
aws acm describe-certificate \
  --region us-east-1 \
  --certificate-arn arn:aws:acm:us-east-1:123456789012:certificate/abc \
  --query 'Certificate.DomainValidationOptions[0].ResourceRecord'

The ACM validation CNAME record serves two purposes: it proves domain ownership during initial certificate issuance, and it allows ACM to automatically renew the certificate every 13 months without manual intervention. A common mistake is deleting the validation CNAME after the certificate is issued — everything works until renewal time, when the domain association suddenly shows PENDING_VERIFICATION and the site goes down. Leave the validation CNAME in DNS permanently.

For domains at Cloudflare, the simplest apex domain setup is Cloudflare's CNAME flattening: create a CNAME record for the zone apex (yourdomain.com CNAME {appId}.cloudfront.net) and Cloudflare transparently resolves it to an A record, bypassing the DNS specification that prohibits CNAMEs at the apex. For Namecheap, GoDaddy, or other providers that don't support CNAME flattening, configure a URL redirect from the apex to www — a slightly worse user experience but functionally correct. The update-domain-association command replaces all configured subdomains, just like update-branch replaces all variables: always include every subdomain mapping in a single call.

Consolidated failure modes

FailureSymptomFix
Wrong platform: WEB for Next.js SSRDeployed site returns blank HTML or 404 on server-rendered routesSet platform: WEB_COMPUTE in the Amplify App for Next.js SSR; WEB is for static export only — no Lambda@Edge functions are provisioned without WEB_COMPUTE
Node.js version mismatchBuild fails with "unsupported engine" or native module compile error; Amplify default is Node 16Add nvm use 20 as the first preBuild command in amplify.yml
Wrong baseDirectory for build outputBuild succeeds, site returns 403 — Amplify deployed an empty directoryVite → dist/; CRA → build/; Next.js WEB_COMPUTE → .next/; Next.js static export → out/. Verify the path with ls in a postBuild command
SPA deep links return 404Direct navigation to non-root path returns AccessDenied from CloudFrontAdd the SPA rewrite customRule mapping unmatched paths to /index.html with status: '200'
update-branch wipes existing variablesBuild fails after variable update because other variables disappeared--environment-variables is a full replacement — always include all branch-level variables in every update-branch call
Variable update not reflected in buildUpdated variable value not used after changing it in console or CLIAmplify does not auto-redeploy on variable change — trigger a new build with aws amplify start-job --job-type RELEASE
Runtime variable undefined in Next.js API routesprocess.env.DATABASE_URL is undefined in server-side code at runtimeNo-prefix runtime variables must be set at app level — branch-level variables don't flow to Lambda@Edge runtime for SSR
ACM certificate not found in troubleshootingaws acm list-certificates returns empty list even though certificate existsAmplify ACM certificates are always in us-east-1 — add --region us-east-1 to all aws acm commands
ACM validation CNAME deletedDomain association shows PENDING_VERIFICATION during renewal (~13 months after initial setup)The ACM validation CNAME must remain permanently in DNS for auto-renewal to work — recreate it using aws acm describe-certificate --region us-east-1 to get the record values
update-domain-association drops subdomainsPreviously mapped subdomain disappears after adding a new oneupdate-domain-association --sub-domains replaces all mappings — include every subdomain in a single call
defineData with pure DynamoDB (no GraphQL)Schema validation error or TypeError when bypassing GraphQL DSLdefineData only supports AppSync+DynamoDB via GraphQL schema — for pure DynamoDB without GraphQL, use defineBackend with CDK aws_dynamodb.Table directly
amplify_outputs.json not found in buildFrontend build fails with "cannot find amplify_outputs.json"The backend.phases block in amplify.yml must run before frontend.phases — pipeline-deploy writes amplify_outputs.json which the frontend build reads
Gen 2 GSI sort key composite fieldsQuery returns wrong results or empty set when querying secondary indexAmplify concatenates multi-field sort keys with # — the DynamoDB sort key value is "field1#field2"; update query expressions to use the concatenated format
Cognito trigger not firingPre-sign-up Lambda defined but not invoked during user registrationTriggers must be registered via defineAuth — manually adding the Lambda to the Cognito User Pool after deployment doesn't grant the Cognito invocation permission that Amplify wires automatically
PR preview pointing to production APIPR preview mutations affect production dataSet REACT_APP_API_URL to staging at app level; override to production only on the main branch; or use $AWS_BRANCH in amplify.yml to conditionally set the URL

Production checklists

Amplify Hosting deployment pipeline

Custom domain and ACM

Gen 2 IaC and sandbox

Configuration and secrets management