A production checklist for apps built with AI, with a test for each item.
AI builders produce working demos fast, and the same few gaps show up again and again: secret keys shipped to the browser, API routes that never check who is asking, no tested backup, no rate limit on login. Below are nine checks to run before real users arrive, each with the command or click that proves it.
Facts checked
The nine checks
| Check | Passes when |
|---|---|
| 1. Secrets stay on the server | A search of the built browser bundle and the git history finds no secret key |
| 2. Every route checks the user | Signed in as user B, you can’t read or change user A’s records |
| 3. Configuration is per environment | No .env in git; the app refuses to boot without its required variables |
| 4. Errors are logged, not shown | A forced error shows visitors a plain page and shows you the stack in the logs |
| 5. Backups restore | You restored one into a scratch database and found your data |
| 6. Your own domain works end to end | Sign-in, payments and webhooks work on the real domain over HTTPS |
| 7. Expensive routes are rate-limited | The 30th login attempt in a minute gets a 429 |
| 8. Logs are searchable and clean | You can find a request from an hour ago, and no password or token appears |
| 9. Law 25 basics are in place | Privacy policy and contact published, incident register started, data location known |
1. Secrets stay on the server
This is the most common hole in generated code. Anything the browser downloads is public. Next.js inlines every NEXT_PUBLIC_ variable into the JavaScript it sends to the browser at build time, and Vite does the same with VITE_ variables; Vite’s docs say they “should not contain sensitive information such as API keys.” A Stripe secret key, an OpenAI key or a Supabase secret (service_role) key under one of those prefixes is a key anyone can copy.
Verify: build locally and search the output, then search the history. Any hit means move the call to a server route and rotate the key, because deleting the line does not un-publish it.
# Build the way production does, then search what ships to the browser
npm run build
grep -rEo "sk_live_[A-Za-z0-9]{8,}|sk-[A-Za-z0-9_-]{20,}|service_role|SECRET" \
.next/static dist build 2>/dev/null | sort -u
# And the history: a key committed once is a key to rotate
git log -p | grep -nE "sk_live_|sk-[A-Za-z0-9_-]{20,}|service_role" | head2. Every route checks who is asking
Generated apps often protect the page and forget the API behind it. The page hides the button; the endpoint still answers anyone who calls it. Each route that reads or writes user data has to look up the signed-in user and filter by it on the server.
Verify with two accounts. Sign in as B, open one of A’s records by changing the ID in the URL or the request, and try to edit it. You should get a 403 or 404. Then call the same API with no cookie at all: curl -i https://yourapp.ca/api/orders/123 should return 401.
Apps exported from Lovable or Bolt usually talk to Supabase straight from the browser, so the database policies are the only check. Supabase’s guide says to enable row-level security on every table in an exposed schema and never to use a secret key in the browser. Run this in its SQL editor:
-- Every table in an exposed schema should say true
select relname, relrowsecurity
from pg_class
where relnamespace = 'public'::regnamespace and relkind = 'r'
order by relrowsecurity, relname;3. Configuration is per environment
- No `.env` in git.
git ls-files | grep -E '(^|/)\.env'should print nothing except an example file. - Fail at boot, not at the first request. Read required variables when the app starts and exit with their names if one is missing. A crash on deploy is cheaper than a broken checkout at 2 a.m.
- Production values are not test values. Live payment keys, the production database, the real email sender.
- Previews don’t touch production data. On Canner, pull-request previews share the project’s database. If the project holds live credentials, turn previews off and keep a separate staging project with sandbox keys (how).
4. Errors are logged, not shown
A stack trace on screen tells an attacker your file paths, library versions and sometimes your SQL. Visitors should get a plain error page; you should get the whole stack.
Verify: hit a route with a bad ID or force a throw, and read the response body. It should not contain a stack. Django needs DEBUG = False; Node frameworks need NODE_ENV=production, which Canner sets for you. Then find that same error in your logs.
On Canner, Logs groups errors from Node, Python, Ruby, PHP, Go, Java and Rust, counts them per deploy and links each to the commit it started in. A Next.js “Application error … Digest” can be pasted into the search box to find the real error (logs and errors docs).
5. Backups that restore
A backup you have never restored is a guess. Turn on scheduled backups, then restore one and look at it.
On Canner, a backup policy on the Database tab runs daily on Starter and as often as hourly on paid plans. Restore with Inspect first: the backup lands in a temporary database you can query and compare with live, without touching production. Canner also restores your newest backup overnight to test it and marks it Verified. See Postgres on Canner and the database docs.
Backups contain data, not configuration. Export your environment variables (canner env --reveal --json) into a password manager, never next to the backup. Without the key that encrypted something inside your data, a restore can’t read it.
6. Your own domain, end to end
Adding the domain is the easy part: two DNS records, and Canner issues the HTTPS certificate and renews it (custom domains). What breaks is everything that still points at the old preview URL.
- OAuth redirect URIs at Google, GitHub or Microsoft.
- The site URL and redirect allow-list in your auth provider.
- Stripe and other webhook endpoints.
- CORS allow-lists and any
APP_URLorNEXTAUTH_URLvariable.
7. Rate limits on what costs you
Login, sign-up, password reset, contact forms and any route that calls a paid AI API. Without a limit, the first is a password-guessing target and the last is a bill.
A Canner project runs one process, so an in-memory limiter sees all of the project’s traffic. It resets on every deploy and wake, which is fine for login throttling. For limits that must survive a restart, such as a daily AI quota per user, count in Postgres; Canner does not run Redis.
Verify:
# 30 bad logins in a row: the last ones should be 429, not 401
for i in $(seq 1 30); do
curl -s -o /dev/null -w "%{http_code} " -X POST https://yourapp.ca/api/login \
-H 'content-type: application/json' -d '{"email":"a@b.c","password":"x"}'
done; echo8. Logs you can search, without secrets in them
You need to answer “what happened to this user at 14:03?” Log one line per request with the user ID, and errors with their stack.
Don’t log request bodies on auth routes, Authorization headers or full card numbers. Canner removes code, state, token, password, api_key and similar query parameters from its own request logs, but it does not touch what your app prints (which ones).
Verify: search your logs for password, Bearer and sk_. Nothing should come back.
9. Law 25 basics for Quebec users
If your app collects personal information from people in Quebec, Quebec’s private-sector privacy act, as amended by Law 25, applies to you whoever you are. These are the parts an app team handles. It is our reading of the Act, not legal advice.
| Section | What it asks | What to do in the app |
|---|---|---|
| 3.1 † | Someone is in charge of protecting personal information (by default the person with the highest authority); their title and contact are published on your website | Add it to the privacy page |
| 8.2 † | A confidentiality policy in clear and simple language, published on your website, when you collect information by technological means | Write it for what your app really collects, link it in the footer and at sign-up |
| 9.1 † | Privacy settings of a product offered to the public give the highest level of confidentiality by default (cookie settings excepted) | Optional sharing, public profiles and tracking start off |
| 3.5 and 3.8 † | Reduce the risk after a confidentiality incident, promptly notify the Commission d’accès à l’information and the people affected when there is a risk of serious injury, and keep a register of incidents | Start the register now; know who decides and how you would email affected users |
| 17 † | Before personal information is communicated outside Quebec, including to a provider that keeps it for you, assess the privacy risks | List every place user data goes: host, database, email, analytics, and the AI API your features call |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. 3.1 (2026-10-29) · 8.2 (2026-10-29) · 9.1 (2026-10-29) · 3.5 and 3.8 (2026-10-29) · 17 (2026-10-29)
Questions
Is my Supabase anon or publishable key a secret?
No. It is meant to be in the browser, and row-level security is what protects your data. The secret (service_role) key bypasses row-level security and must never reach the browser.
How do I check if my AI-built app leaks API keys?
Build it the way production does and search the browser output (.next/static, dist or build) for key patterns such as sk_live_ and sk-, then search the git history. Rotate any key you find, even after you remove it.
Do I need a privacy policy for a small app?
If you collect personal information from people in Quebec through a website or app, section 8.2 of the private-sector act asks for a confidentiality policy in clear and simple language, published on your website.
Can I keep Supabase and host the app on Canner?
Yes. The app runs on Canner and keeps calling Supabase. Your data then lives where your Supabase project is, which matters for section 17 if that is outside Quebec. Moving the database to Canner Postgres is optional.
Launch it in Montréal.
Free to start, with Postgres, backups you can test, and logs on every plan. More guides for apps built with AI are in Host AI-built apps.