All posts
7 min read

Scripting a Full AWS Amplify Deploy: No Console Clicks

How to take a React Router 7 app from local folder to a live custom domain using only the AWS CLI, with the exact flags and errors you'll hit, and why the console workflow doesn't script.

AWS
Amplify
React Router
Software Development
Scripting a Full AWS Amplify Deploy: No Console Clicks

The AWS CLI commands that take a React Router 7 app from local folder to a live custom domain, no console clicking required, plus the exact errors you'll hit doing it.


I already wrote about deploying React Router 7 to Amplify with full SSR. That post is the console walkthrough: click Create app, click Host web app, pick your repo. It works, and if you're deploying once, do that.

I stopped doing it that way once I had more than one site to stand up. This is the CLI version: the one I actually run now, end to end, from an empty GitHub repo to a working https://yoursubdomain.yourdomain.com. It's also the version that scripts, which the console workflow doesn't.

--platform WEB_COMPUTE

aws amplify create-app \
  --name my-app \
  --repository "https://github.com/you/my-app" \
  --access-token "$GH_TOKEN" \
  --platform WEB_COMPUTE \
  --enable-branch-auto-build

--platform WEB_COMPUTE is the one flag in this command you can't get wrong. Skip it and Amplify creates a static-site (SSG) app, so your React Router SSR Lambda never gets provisioned. Every loader that hits a database or reads a request header quietly stops running. The build succeeds, the site loads, and your dynamic routes just serve stale or broken output with nothing pointing at the cause.

Don't pass --custom-rules alongside WEB_COMPUTE either. Those are SPA-fallback rewrite rules for static hosting, and on a compute app they fight with the SSR routing Amplify already generates from your build manifest. The regex also doesn't survive shell escaping cleanly, which is a second, dumber way to lose an afternoon to the same flag.

Default branch already exists

Amplify auto-creates the default branch (usually main) the moment create-app runs. If your script's next step is:

aws amplify create-branch --app-id "$APP_ID" --branch-name main

you get:

BadRequestException: The branch main already exists

Use update-branch instead. It's also where you set env vars and the production stage:

aws amplify update-branch --app-id "$APP_ID" --branch-name main \
  --framework "React" \
  --stage PRODUCTION \
  --enable-auto-build \
  --environment-variables KEY1=val1,KEY2=val2

One gotcha inside that gotcha: update-branch --environment-variables replaces the full set, not merges. I checked this against a real branch rather than assume it: setting one new variable without the existing ones wiped all of them. If you're adding one variable to a branch that already has three, read the existing ones first (aws amplify get-branch --app-id "$APP_ID" --branch-name main --query "branch.environmentVariables") and pass all four back, or the other three silently disappear.

Custom domain DNS records

aws amplify create-domain-association --app-id "$APP_ID" --domain-name example.com \
  --sub-domain-settings "prefix=myapp,branchName=main"

This returns immediately with CREATING, then moves to PENDING_VERIFICATION. At that point get-domain-association gives you two DNS records to install: a certificate-validation CNAME and a traffic CNAME. This is where a scripted deploy earns its keep. You can pull both records and write them straight into Route 53 without touching a UI:

aws route53 change-resource-record-sets \
  --hosted-zone-id "$HOSTED_ZONE_ID" \
  --change-batch file://dns-records.json

The trap: it's easy to install only the traffic CNAME and miss the certificate-validation one, because the traffic record looks like the one that matters (it's the domain you're trying to make work) and the validation record looks like AWS noise. Without both, the association just sits at PENDING_VERIFICATION indefinitely, no error, no timeout. Check both landed:

aws route53 list-resource-record-sets --hosted-zone-id "$HOSTED_ZONE_ID" \
  --query "ResourceRecordSets[?contains(Name,'myapp')||contains(Name,'acm-validations')]"

If that query returns one record instead of two, that's the whole bug.

Gotchas

ERR_MODULE_NOT_FOUND at Lambda startup isn't a CLI issue. It means your vite.config.ts is missing the SSR inlineDynamicImports build option, covered in the console-deploy post under "Gotchas and Troubleshooting." The fix is the same whether you deploy by CLI or console.

Cannot find module '@rollup/rollup-linux-x64-gnu' is npm's optional-dependency resolution skipping the Linux binary when your lockfile was generated on macOS or Windows. Fix it in the build spec, not locally: add npm install @rollup/rollup-linux-x64-gnu --save-optional --legacy-peer-deps as an explicit preBuild step in amplify.yml, after wiping node_modules and package-lock.json.

If the domain's stuck at PENDING_VERIFICATION past 30 minutes, it's almost always the missing-CNAME issue above. Confirm both records with the list-resource-record-sets query before assuming it's an AWS-side delay.

I've also seen vite-plugin-react-router-amplify-hosting@0.7 builds fail where 0.4 succeeds, on a handful of repos, without a clear explanation why. If you hit an inexplicable build failure on the current version and the diff makes no sense, npm install --legacy-peer-deps --save-dev vite-plugin-react-router-amplify-hosting@^0.4.0 is a real, if unsatisfying, fallback.

Versions this held true for

Node 20+, AWS CLI v2, React Router 7.x. This site itself runs on vite-plugin-react-router-amplify-hosting@^0.4.0, the fallback mentioned above, not the newer 0.7.x line. Amplify's CLI surface moves slower than most AWS services, but if a flag here doesn't match what you're seeing, check aws amplify create-app help against your installed CLI version first.

Get StartedSkip the discovery call

Have something hard to build?

Build an estimate in three minutes, or browse typical price ranges by service. Either way, we respond within one business day.