Operator FAQ + Troubleshooting
For hosters running their own fork. End-user-facing FAQ is at FAQ; this page is the operator-side companion.
Cloud deploy
Cloud Run keeps spinning up new revisions but they all fail health check
Likely the container can't reach Cloud SQL. Cloud SQL by default requires either:
- A Cloud SQL connector sidecar (Cloud Run flag
--add-cloudsql-instances=<project>:<region>:<instance>+DATABASE_URLusing the unix-socket formpostgres:///dbname?host=/cloudsql/<connector>) - OR a public IP + authorised network — set
DATABASE_URLto the public IP with TLS-required, and add Cloud Run's egress range to Cloud SQL's authorised networks.
Reference deployment uses option 1 (connector). Option 2 is simpler to set up but trickier to secure.
My Cloud Run revision dies on cold start, ~3-5 seconds in
Almost always a missing env var crashing Django at boot. Check the revision's logs:
gcloud logging read \
'resource.type=cloud_run_revision AND resource.labels.service_name=afriannotate' \
--limit 50 --format json | jq -r '.[] | .textPayload // .jsonPayload.message'
Common offenders:
SECRET_KEYnot set → Django raises ImproperlyConfiguredDATABASE_URLnot set or wrong → boot dies on first ORM callALLOWED_HOSTSdoesn't include the Cloud Run URL → 400 on every health check probe (Cloud Run probes from internal IPs by default, so*or your explicit Cloud Run hostname needs to be in there)
Build succeeds locally but gcloud builds submit fails
The .gcloudignore may be excluding files needed in the build context.
Check what's actually in the build context:
gcloud builds submit --dry-run --tag dummy 2>&1 | head -20
If it's including node_modules (which it shouldn't), add to
.gcloudignore:
node_modules/
.git/
docs/
docs-site/build/
native/*/node_modules/
native/*/ios/
native/*/android/
My deploy is slow (10+ min) every time
The Docker image is probably huge. Check:
gcloud container images describe gcr.io/<project>/afriannotate:latest \
--format='value(image_summary.fully_qualified_digest)'
docker pull <digest>
docker history <digest> | head -20
If the image is >500 MB, you're shipping dev dependencies. Make sure the Dockerfile uses a multi-stage build (build SPA + Python wheels in a build stage, copy only the artifacts into the final stage).
How long can annotators stay signed in — including offline on mobile?
Sessions refresh silently: the app exchanges its 30-day refresh token for fresh 24-hour access tokens in the background, so annotators only see the login screen when the refresh token itself expires. On mobile that 30-day window is also the offline-auth policy — a signed-in phone keeps working offline until it lapses. Deployments with longer offline stretches raise it with an environment variable on the server:
AFRI_REFRESH_TOKEN_DAYS=90 # default 30
Longer lifetimes trade convenience for exposure on a lost device — pair long values with per-project mobile enablement ("Allow on mobile") kept tight.
Everyone in one organisation sees "Uh oh, something went wrong"
Check the Cloud Run logs for AttributeError: 'NoneType' object has no attribute 'email' in core/feature_flags/utils.py. That was the
signature of an organisation with no owner: its owner account had been
deleted and Organization.created_by was NULL. Since the 12 September
2026 fix the page no longer crashes and owner deletion can no longer
produce an ownerless organisation, but on an older build, or after a
manual database edit, the repair is:
- Find the organisation:
./deploy/gcp/run-manage.sh shell -c "from organizations.models import Organization; print(list(Organization.objects.filter(created_by__isnull=True).values('id','title')))" - Either transfer it (
POST /api/organizations/<id>/transferas platform staff, or Organization → Settings → Danger Zone once you can load the page) or, if it is empty, delete it:./deploy/gcp/run-manage.sh destroy_organization <id>.
run-manage.sh runs any manage.py command against the production
database as a one-off Cloud Run Job with the currently serving image.
It touches live data: read what you are about to run twice.
Email deliverability
"I deployed but no email arrives anywhere — not Gmail, not Outlook, not anywhere"
The cloud isn't talking to your SMTP provider at all. Check in this order:
- Are the SMTP env vars set on Cloud Run? Common miss: setting
them only in
.env(used for local dev) but not in the Cloud Run service config.gcloud run services describe afriannotate --region=<region> --format='value(spec.template.spec.containers[0].env)' - Does the provider show the message in its outbound log? Brevo has "Statistics → Sent". SendGrid has "Activity Feed". If it's not there, the cloud never tried to send. If it IS there, the problem is downstream (the recipient's filter).
- Did the provider reject it for a bad From address? Free tiers
typically require your sender domain to be verified. Until you
verify, every send returns
554 Sender Not Verified.
Gmail delivers but every corporate address bounces
Common. Corporate mail gateways (Mimecast, Microsoft ATP, university IT, Cisco IronPort) are way stricter than consumer providers. The bounce typically says:
550-5.7.1 The user or domain that you are sending to has a policy
that prohibited the mail that you sent.
Three things to try, in order of escalation:
- Mark the user verified manually so they can sign in at least — see Mark a user verified.
- Ask the recipient's IT to allowlist your sender. Most corporate IT desks will allowlist a verified, DKIM-signed transactional sender on request. Provide your domain + SPF + DKIM records (from Email delivery).
- Pay for a dedicated IP from your email provider. Shared IPs accumulate reputation noise from other customers. Dedicated IPs that you control + warm up are cleaner. ~$30-50/mo at most providers.
DMARC reports show a high rate of failures from "unknown sources"
Your domain is being spoofed. Common when an attacker tries to use your domain in phishing campaigns. Two things:
- Tighten DMARC: change
p=nonetop=quarantineafter a few weeks of clean alignment reports. - Don't try to add SPF includes for the spoofers — that would
authorise them. The right move is
-all(hard fail) at the end of your SPF + a tightened DMARC.
"My provider says Verified but mail still bounces"
Most providers' "Verified" status only certifies DKIM + domain ownership. It does NOT guarantee SPF is in place. Operators commonly forget the SPF record because the provider's setup wizard doesn't insist on it. Run:
dig @1.1.1.1 +short TXT yoursenderdomain.example.org
If you don't see a v=spf1 ... record, that's it.
Build pipeline
Android build script bails with AFRI_KEYSTORE_PATH not found
The script expects an absolute path to the .jks file. If you
followed the README, it's under
native/mobile/android/keystore/afri-release.jks and gitignored.
Make sure the file actually exists:
ls -la $AFRI_KEYSTORE_PATH
If it's missing, regenerate via
native/mobile/scripts/generate-release-keystore.sh — but understand
that you can never push an update to a Play listing pinned to the
old keystore. Lost keystore = republish under a new package name.
iOS build succeeds but App Store Connect rejects with 409 CFBundleIdentifier Collision
Modern Xcode (15+) rewrites embedded framework Info.plist bundle IDs
during auto-sign archive — every Capacitor framework ends up with
the App's bundle ID instead of its own org.cocoapods.* one. The
build script's post-archive fixup pass restores each framework's
bundle ID + re-codesigns. If you've edited the script and removed
this section, put it back.
iOS upload says Redundant Binary Upload
App Store Connect already has a build with the same CURRENT_PROJECT_VERSION
MARKETING_VERSION. Re-run./scripts/build-release-ios.sh— it auto-bumps the build number on every invocation (seconds since 2023-11-15).
macOS notarization stuck "In Progress" for hours
Apple's notary service occasionally stalls despite green status on developer.apple.com/system-status. Three options:
- Wait (sometimes resolves in 1-6 hours)
- Submit again — the new submission may process while the old one stays stuck
- Open a developer support ticket if it's been > 24h
The DMG itself is fine; the issue is purely Apple's infrastructure.
Database + scale
How many tasks can a single project handle?
~100k tasks / 100k annotations per project is the comfortable upper bound on the smallest Cloud SQL tier. The dashboard's task list and filters get slow past that. Split by workspace if you need more headroom.
What's the recommended Cloud SQL tier for production?
For a few hundred concurrent annotators: db-custom-2-7680
(2 vCPU, 7.5 GiB RAM). For low-traffic teams: db-f1-micro (0.6 GiB
RAM) is genuinely fine — the workload is read-heavy with bursty
writes during annotation submission.
How do I move from the reference cloud to my own?
The full migration:
pg_dumpfrom the reference cloud Postgres.- Copy the GCS bucket contents (
gsutil -m rsync) to your bucket. - Restore the dump into your Postgres.
- Update the
storage_urlfields in the database to point at your bucket (sed across the dump or run a Django data migration). - Update
SECRET_KEY— this invalidates all JWTs, forcing everyone to log in again. Necessary because the JWT signing key is the secret. - Point DNS at your new cloud.
- Run the new cloud + tell users to re-login.
Downtime: ~30 min during the DNS cutover. Annotation work in progress is queued (the JWT outbox) but new logins fail until DNS propagates.
Self-hosting alternatives
Can I run this on AWS instead of GCP?
Yes. The mapping:
| GCP | AWS |
|---|---|
| Cloud Run | ECS Fargate or App Runner |
| Cloud SQL | RDS Postgres |
| Cloud Storage | S3 |
| Cloud Build | CodeBuild |
| Cloud Logging | CloudWatch |
| Cloudflare DNS (or Cloud DNS) | Route 53 |
| Brevo (or any SMTP) | SES |
The app code reads env vars for storage / database / email, so
switching providers is just changing env (STORAGE_TYPE=s3 +
STORAGE_AWS_BUCKET_NAME, etc.). Only the GCP pipeline ships in the
repo — deploy/gcp/ (Dockerfile, provision.sh, deploy.sh,
cloud-run-env.yaml); port its steps to CodeBuild / ECS yourself.
Can I run this on a single VPS?
Yes. The minimal viable single-VPS deployment:
- 2 vCPU / 4 GiB RAM Hetzner CX22 (~€4.50/mo)
- Docker Compose: Django container + Postgres container + Caddy/nginx reverse proxy
- Storage: local disk for uploaded files, or attach a separate Hetzner Volume
Tradeoffs: no autoscaling, no managed backups, you're responsible for the OS patches and the cert renewal. Fine for ≤ 50 active users or staging environments.
Start from the root docker-compose.yml (app + Postgres + nginx;
docker-compose.minio.yml adds S3-compatible storage,
docker-compose.override.example.yml shows local overrides) and
put your own Caddy / nginx TLS terminator in front.
What's next
- Email delivery → — SPF / DKIM / DMARC setup
- Security model → — auth, transport, storage, isolation
- Distribution → — mobile + desktop build flows
- FAQ → — end-user-facing FAQ