If you are an operator bringing up a fresh environment (not a developer running locally), see _docs/configuration.md first — it covers the OAuth login + Studio integration setup. This file documents the Coolify runtime infrastructure, CI/CD, runtime env vars, and bastion / remote-DB access.
A fresh environment that uses Zoom also needs Marketplace configuration outside the application: follow configuration section 10 and the authoritative Server-to-Server OAuth setup, six-scope matrix, and disposable smoke procedure. After changing Zoom scopes, restart both web and worker services so every process-local token cache obtains a newly scoped token, or wait up to one hour. Never validate a fresh environment with a real event or expose a token, secret, recording download URL, or Zoom join/start URL as evidence.
Local development additionally requires Node.js 24 and npm. Tailwind CSS
3.4.17 is pinned in package.json/package-lock.json; make run and
make run2 perform a minified one-time build, while make dev starts the CSS
watcher. make css-build and make css-watch are the canonical explicit
commands. The output at static/css/tailwind.css is generated and gitignored.
community-base (DataTalksClub/community-base) is pinned as a git dependency
at a released vX.Y.Z tag in pyproject.toml, resolved by uv.lock. Both
ci.yml and deploy-dev.yml run scripts/check_community_base_source.py
before installing dependencies; the guard fails the build if the dependency
is ever a local path, editable install, branch ref, or registry copy instead
of the pinned tag, so unreviewed package code cannot reach the site runtime.
To work on the package together with this site, use the reversible local link
(the package repo lives as a sibling checkout, ../community-base):
make core-link— requires cleanpyproject.toml/uv.lock, snapshots their exact bytes under.tmp/core-link/, and points the dependency at the sibling checkout (editable; package edits are visible without reinstall). Refuses when a link is already active or the checkout is missing or is not a community-base repo.make core-unlink— restores the snapshotted pinned dependency and syncs it back. Refuses whenpyproject.tomlhas changes beyond the link edit or when recovery state is missing; it never runs a blanketgit checkout --.
Never commit a linked tree: uv run python -c "import community_base, pathlib; print(pathlib.Path(community_base.__file__).resolve())" must print
a site-packages path before you commit, and grep -n 'path = ' pyproject.toml
must print nothing.
The app runs on Contabo Coolify (https://coolify.169.58.119.163.sslip.io) behind Coolify's Traefik proxy, with immutable linux/amd64 Docker images stored in GitHub Container Registry (ghcr.io/ai-shipping-labs/website). The earlier AWS ECS Fargate runtime and ECR repository are retired; see coolify-deployment.md, coolify-dual-deploy.md, and coolify-networking.md.
| Component | Resource |
|---|---|
| Coolify Source Service | aisl-staging (mekt9kz74fvbixla6olinuma) — migration-capable source web and production worker |
| Coolify Rolling Web App | aishl-production-rolling-web (w3mxfr6wzjnpg3hqaw3y0obq) — public production web routes |
| Image Registry | ghcr.io/ai-shipping-labs/website (deploy-<run_id>-<attempt>-<40-char-sha>) |
| Networks | coolify (PostgreSQL DNS/connectivity) and mekt9kz74fvbixla6olinuma (Service UUID / Traefik) |
| Dev URL | https://dev.aishippinglabs.com (Traefik file-provider route to web-mekt9kz74fvbixla6olinuma:8000; see coolify-dev-route.md) |
| Prod URL | https://aishippinglabs.com |
| AWS Region (S3 / SES / Secrets Manager) | eu-west-1 |
Three runtime containers run from the same GHCR Docker image (scripts/entrypoint_init.py):
web-mekt9kz74fvbixla6olinuma(aisl-stagingServiceweb) — sole migrator (RUN_MIGRATIONS=true,BOOT_MODEunset), runsmanage.py migrate, R1 reconciliation/schedule suppression, andsetup_schedules, then starts Gunicornworker-mekt9kz74fvbixla6olinuma(aisl-stagingServiceworker) — background worker (BOOT_MODE=worker,RUN_MIGRATIONS=false), skipsmigrate, runs R1 reconciliation/schedule suppression andsetup_schedules, then starts Django-Q2qclusteraishl-production-rolling-web(w3mxfr6wzjnpg3hqaw3y0obq) — public rolling web (BOOT_MODE=web,RUN_MIGRATIONS=false), skipsmigrate, runs R1 reconciliation/schedule suppression andsetup_schedules, then starts Gunicorn behind/opt/rolling-launch.py
The email_app migration 0013_create_django_q_cache_table creates the django_q_cache DatabaseCache table as part of migrate, so it is applied automatically on every deployment without a separate createcachetable step on every container boot.
The deployed version tag is baked into the image VERSION environment variable (the full 40-character commit SHA) and returned by /ping and displayed in the page footer.
The Coolify Service Compose definition and Application environment carry the platform environment variables in this section (deploy/coolify/topology.py owns the protected runtime contract). Most rows here are read at process start, used by Django or the entrypoint directly, or intentionally excluded from Studio; rows that also have Studio overrides call that out explicitly.
Set in the Coolify Service/Application environment or image build:
| Variable | Example | Purpose |
|---|---|---|
VERSION |
e79168d6974422fa345132c45b81467efd7eeaab |
Baked into the image at build time (GITHUB_SHA); displayed in the page footer and /ping. Must not be overridden in Coolify runtime env (reject_version_override). |
DEBUG |
0 |
Django debug mode. Truthy values: 1, true, yes. Anything else (including 0, false, no, empty) is falsy. Production must set this to 0 / false to enforce the SECRET_KEY guard. |
ALLOWED_HOSTS |
dev.aishippinglabs.com,aishippinglabs.com |
Comma-separated list of allowed hosts |
CSRF_TRUSTED_ORIGINS |
https://dev.aishippinglabs.com,https://aishippinglabs.com |
Required for POST requests (login, forms) to work over HTTPS |
SITE_BASE_URL |
https://aishippinglabs.com |
Process-start baseline for absolute URLs. Runtime URL generation can be overridden in Studio > Settings > Site. |
BOOT_MODE |
unset on source web, worker on source worker, web on rolling web |
Entrypoint role selector (scripts/entrypoint_init.py). web and worker skip migrate on boot; when unset, RUN_MIGRATIONS selects web-migrator vs worker. |
RUN_MIGRATIONS |
true on source web, false on worker and rolling web |
Entrypoint migration flag. Only the source web container runs migrate. Set by the Coolify topology (deploy/coolify/topology.py). |
SERVING_BOOT_SMOKE_CHECK_ENABLED |
false on source Service |
Controls the pre-Django raw-env smoke check in scripts/entrypoint_init.py (defaults to true when unset). |
GUNICORN_WORKERS / GUNICORN_THREADS |
2 / 4 |
Gunicorn worker and thread counts for scripts/gunicorn_runtime.py. |
RELAY_BASE_URL / RELAY_API_KEY |
configured in prod | Required so transactional/campaign email deliver through Relay. Missing values fail manage.py check when DEBUG=False. |
S3_ENABLED |
true in prod |
Uploads content-sync images to S3. Defaults true — a missing var resolves to true so uploads run; content sync only skips image upload when the flag is explicitly false (or under TESTING). Keep it true in prod so uploads never depend on the default drifting. |
SLACK_ENABLED |
true where the Slack bot/imports should run |
Startup gate. Studio also has Slack settings, but if this env var is false, Slack token/channel settings are blanked at import time. |
SLACK_ENVIRONMENT |
development on dev, production on prod |
Slack routing mode. Non-production modes ignore production Slack channel IDs and require dev/test channel overrides before posting. Can be managed in Studio for normal routing changes after the startup gate is enabled. |
Q_WORKERS |
2 |
Optional django-q worker count. Defaults to 1 on SQLite, 2 on Postgres. |
IP_HASH_SALT |
random string | Optional salt for analytics IP hashes. Empty means IP hashes are not stored. |
ANALYTICS_COOKIE_DOMAIN |
.aishippinglabs.com |
Optional analytics cookie domain override. |
EMAIL_BATCH_SIZE |
200 |
Optional campaign-send chunk size. |
IMPORT_WELCOME_EMAILS_PER_HOUR |
50 |
Optional throttle for imported-user welcome emails. |
RELAY_UNSUBSCRIBE_EMAIL |
unsubscribe@aishippinglabs.com |
Optional mailto address for the List-Unsubscribe email header. Not rendered in Studio. |
LOGIN_API_SLOW_MS |
750 |
Optional slow-login instrumentation threshold in milliseconds. |
Configure the following runtime controls in Studio > Settings > Site. Their environment values remain process-start fallbacks for a fresh database or an environment that has not saved an override.
| Variable | Default | Purpose |
|---|---|---|
SYNC_QUEUED_THRESHOLD_MINUTES |
10 |
Minutes a sync may remain queued before the watchdog marks it failed. |
SYNC_RUNNING_THRESHOLD_MINUTES |
30 |
Minutes a sync may remain running before the watchdog marks it failed. |
EXPECT_WORKER |
true |
Set false only for a one-off environment intentionally running without django-q. |
Set as secret environment variables in Coolify (Service aisl-staging and Application aishl-production-rolling-web):
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string on the coolify network |
SECRET_KEY |
Django secret key. Required at startup when DEBUG=false: the app raises ImproperlyConfigured and exits if it is unset, empty, or equal to the in-tree dev fallback. The fallback django-insecure-dev-only-do-not-use-in-production is rejected on purpose so a copy-paste from local dev cannot satisfy the production guard. |
Fetched at runtime by the Django app when no direct PEM is configured in Studio or the Coolify environment:
| Secret ID | Purpose |
|---|---|
ai-shipping-labs/github-app-private-key |
GitHub App PEM key for private content repo sync |
The AWS Secrets Manager fallback remains because the authenticated Coolify
source Service and production web Application environments do not define
GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_FILE. Keep
integrations/services/github_app.py:_resolve_github_app_private_key() until a
direct PEM is configured and verified for both Coolify runtimes. The fallback
uses GITHUB_APP_PRIVATE_KEY_SECRET_ID, defaulting to
ai-shipping-labs/github-app-private-key in eu-west-1.
When adding a new environment, make sure ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS include all domains that will submit forms to it.
The app also has test-only/internal env controls:
| Variable | Scope | Purpose |
|---|---|---|
DJANGO_TEST_DB_NAME |
CI/test | File-backed SQLite test DB for --keepdb caching. |
Q_SYNC |
test/debug | Runs django-q tasks synchronously when true; do not set in deployed services. |
DJANGO_QCLUSTER_PROCESS |
internal | Set by scripts/entrypoint_init.py before starting qcluster; operators should not set it manually. |
Content syncing uses one GitHub App. The App's installation in the org grants the platform access to specific repos.
Recommended setup: at https://github.com/organizations/AI-Shipping-Labs/settings/installations, open the installation (ID 117839867) and set Repository access to "All repositories". With this setting, any new content repo in the AI-Shipping-Labs org becomes syncable without revisiting installation settings.
To onboard a new content repo:
- Confirm the App installation has access to it (covered automatically by "All repositories"). Otherwise add it under "Only select repositories".
- Add a
ContentSourcerow pointing at the repo (is_private=Truefor private repos). Use/studio/sync/sources/new, import a source configuration in Studio, or editseed_content_sources.pyand re-runmanage.py seed_content_sources. - Run
uv run python manage.py sync_content(or push to the repo if the webhook is wired).
To verify the App can reach a specific repo:
uv run python -c "
import django, os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'website.settings')
django.setup()
from integrations.services.github import generate_github_app_token
import requests
t = generate_github_app_token()
r = requests.get('https://api.github.com/repos/AI-Shipping-Labs/<repo-name>',
headers={'Authorization': f'token {t}'}, timeout=10)
print(r.status_code, r.json().get('full_name') or r.json().get('message'))
"A 200 with the full repo name means it works. A 404 means the installation is not granted access to that repo.
django-q writes cluster heartbeats to Django's cache backend. The /studio/worker/ dashboard reads them back via Stat.get_all() to decide whether the cluster is alive. If the cache backend is per-process — which is the default LocMemCache — the gunicorn / runserver process never sees heartbeats written by the qcluster process, and the dashboard reports "Worker NOT running" forever, even when the cluster is healthy. The same problem exists per-container: a FileBasedCache works inside a single container but is invisible across separate web and worker containers because each container has its own filesystem.
The project ships a dedicated django_q cache for this. It is LocMemCache during tests (single-process, fast, isolated) and DatabaseCache everywhere else.
| Setting | Test mode | Local dev / Production |
|---|---|---|
CACHES['django_q']['BACKEND'] |
locmem.LocMemCache |
db.DatabaseCache |
CACHES['django_q']['LOCATION'] |
django-q-test |
django_q_cache (a DB table) |
Q_CLUSTER['cache'] |
django_q |
django_q |
DatabaseCache requires a one-time table creation. The email_app migration 0013_create_django_q_cache_table runs createcachetable django_q_cache as a RunPython step so the table is created on a fresh manage.py migrate everywhere — local dev, CI test DB, and the Coolify runtime — without a separate one-shot command. To recreate it manually if the table is dropped:
uv run python manage.py createcachetable django_q_cacheThe command is idempotent.
Register or refresh recurring django-q schedules after setup and deploys:
uv run python manage.py setup_schedulesThe command builds and validates the complete declarative set before writing,
then updates every managed Schedule row in one atomic transaction. It is
idempotent, preserves operator-unknown schedules, and never leaves a partially
updated set when validation or a database write fails.
Web and worker boot run this reconciliation but remain fail-open for site
availability: a failure is logged, existing crons remain unchanged, and the
container continues starting. The durable degraded result is visible on
/studio/worker/ and from staff-token GET /api/diagnostics/schedules.
Recovery runs again on the next container boot and every 15 minutes through
reconcile-schedules; an operator can also run setup_schedules manually and
receive the original validation or apply error directly.
The external user import schedules are:
| Schedule | Source | Cron UTC | Notes |
|---|---|---|---|
import-slack-daily |
Slack workspace | 0 3 * * * |
Runs a live system import and sends imported-user welcome emails through the existing throttle. |
import-stripe-daily |
Stripe customers | 30 3 * * * |
Runs a live system import and sends imported-user welcome emails through the existing throttle. |
Course-db remains a manual CSV import from Studio and is not scheduled automatically.
Staff can review scheduled import history in Studio at /studio/imports/. Superusers can disable or re-enable the Slack and Stripe daily schedules from that page; disabling pauses the django-q schedule without deleting historical ImportBatch rows. After three consecutive failed scheduled batches for the same source, the app sends one admin alert with the source, latest batch id, summary, and Studio review path. A later successful scheduled batch resets that failure streak.
Why DatabaseCache: the application database is the only thing every web and worker process is guaranteed to share, in every deployment topology — local SQLite, multi-container Coolify, future hosts. No extra infrastructure (Redis, EFS) needed. The latency cost (one query per heartbeat read) is negligible at the dashboard's request rate.
We deliberately do not use Redis: avoiding the operational dependency is a product decision. Do not add Redis, Memcached, or another cache service for request-path helpers.
Request-path helpers (get_config / is_enabled / site_base_url, announcement banner, active redirects, public downloads nav, marketing-page nav) must not treat a DatabaseCache GET as free. Every GET is SQL. Those helpers read through integrations.shared_cache, which memoizes the shared-cache GET per request and with a bounded in-process TTL of at most 5 seconds. DatabaseCache remains the cross-process source of truth for django-q heartbeats and for publishing invalidation. Studio saves become visible to other processes no later than that TTL.
If you change CACHES, also keep Q_CLUSTER['cache'] pointing at the same named cache. The wiring is asserted in studio/tests/test_worker_health_cache.py::DjangoQCacheWiringTest. Query-budget tests that count SQL against the cache table must force DatabaseCache; LocMem hides the amplification.
For local development:
make setup
make devmake dev starts the web server, django-q worker, and Tailwind watcher using
Procfile.dev; the application processes use
SITE_BASE_URL=http://localhost:8000. To run one process at a time:
make run
make workerdocker compose up starts db, web, worker, and watcher. One-shot
migrate / seed / content-source registration / _content-repo sync is:
docker compose --profile setup up setupEach Compose app service supplies a command:. entrypoint.sh execs those
arguments when present, so setup, web, worker, and watcher run the commands
in docker-compose.yml instead of falling through to
scripts.entrypoint_init. Deployed Coolify containers start with an empty
argv (or /opt/rolling-launch.py on the rolling web Application), and
BOOT_MODE / RUN_MIGRATIONS dispatch stays in scripts/entrypoint_init.py.
For the Coolify runtime, the same GHCR Docker image runs the source web
migrator, the source worker, and the rolling public web container. A
disposable Node build stage installs the npm lockfile and compiles minified
CSS; only that bundle is copied into the Python image before collectstatic
fingerprints and creates gzip/Brotli variants. Node, npm, node_modules, and
Tailwind are absent from the runtime image. scripts/entrypoint_init.py
imports Django once, applies migrations only when RUN_MIGRATIONS=true on the
source web container, runs R1 reconciliation and schedule registration, then
starts gunicorn for web containers or qcluster for the worker container.
After a fresh setup or deploy, manually reconcile recurring jobs when needed:
uv run python manage.py setup_schedulesNormal web and worker boot already perform the same validate-then-atomic
reconciliation. Boot logs and records failures without crashing the container;
operators can inspect /studio/worker/ or staff-token
GET /api/diagnostics/schedules. Recovery is the next container boot, the
15-minute reconcile-schedules tick, or the manual command above.
Verify a running environment:
curl -fsSL https://dev.aishippinglabs.com/ping
curl -fsSL https://aishippinglabs.com/pingThe response body is the deployed VERSION tag.
Two GitHub Actions workflows handle deployment and production release attestation:
deploy-dev.yml— runs all validation gates, then builds one exact-head GHCR image and deploys the split Coolify runtime (aisl-stagingsource web and worker followed byaishl-production-rolling-web). Triggers automatically on push tomain;coolify-deployment.mdowns its target and verification contract.deploy-prod.yml(Manual Production Deployment) — manualworkflow_dispatchwith a confirmation checkbox (confirmProdDeploy). Runsdeploy.coolify.attest_productionto attest that the greenDeploy Devrelease is running and healthy across both Coolify resources without restarting containers, then verifies production sitemap and robots. Usesconcurrency.groupproduction-coolify-attestationwithcancel-in-progress: false.
Both workflows use the coolify-staging GitHub environment documented in coolify-deployment.md.
CI installs Python dependencies with uv sync --locked. The --locked flag does two things in one:
- It asserts that
uv.lockis already in sync withpyproject.tomland exits 1 with "requirements are unsatisfiable" (or a similar "lockfile out of date" message) if it isn't. This is the drift detection the contract relies on. - It refuses to mutate
uv.lockduring the sync. The committed lockfile is what gets installed, byte-for-byte.
We deliberately do not use uv sync --frozen (alone). Empirically on uv 0.10.11, uv sync --frozen exits 0 even when pyproject.toml and uv.lock have drifted — it skips the resolver entirely and installs whatever is in the lock, which is the silent-mismatch trap the contract exists to prevent. And the two flags cannot be combined: uv sync --frozen --locked is rejected by uv 0.10.11 with "the argument '--frozen' cannot be used with '--locked'". --locked alone is the correct choice — it gives us drift detection AND lock immutability in a single flag.
The contract is: update uv.lock locally and commit it on the same branch as the dependency change before deploying. To update dependencies:
- Run
uv sync(oruv add <pkg>/uv lock --upgrade-package <pkg>) locally. - Commit the updated
uv.lock(andpyproject.tomlif changed) on the same branch as the dependency change. - Push. CI will then install the new resolution exactly.
If a deploy fails at the "Install dependencies" step with a message about uv.lock being out of date or requirements being unsatisfiable, the fix is to regenerate the lock locally and commit it — not to retry the deploy.
There is no local deploy script. The AWS ECS runtime and its ECR repository are retired, and the deploy/deploy*.sh / deploy/update_task_def.py helpers and prod-emergency-* ECS workflows were removed with it. Every deploy goes through Deploy Dev on push to main (or workflow_dispatch), which deploys the exact-head GHCR image to the split Coolify runtime; coolify-deployment.md owns the target, verification, and redeploy contract.
Keep the expanded schema and follow expand-contract-releases.md. Do not reverse schema migrations during an incident rollback.
For read-only incident diagnostics against the Coolify runtime:
- Check
https://aishippinglabs.com/pingandhttps://dev.aishippinglabs.com/ping. - Run
uv run python deploy/coolify/verify_dual_runtime.pyto inspectweb-mekt9kz74fvbixla6olinumaandworker-mekt9kz74fvbixla6olinumacontainer health, shared image identity, networks, and the worker's own-host heartbeat probe without exposing environment secrets. - Query
uv run asl raw GET /api/diagnostics/boot-timing,uv run asl raw GET /api/diagnostics/schedules, anduv run asl worker failed-tasksfor application boot and worker state.
To roll back to a prior known-good immutable GHCR release (ghcr.io/ai-shipping-labs/website:deploy-<run_id>-<attempt>-<40-char-sha>), either push a revert commit to main so Deploy Dev builds and rolls out the revert, or re-promote the prior image, digest, and 40-character SHA through uv run python -m deploy.coolify.deploy_exact_release as documented in coolify-deployment.md. That helper updates both web and worker in Service aisl-staging (mekt9kz74fvbixla6olinuma) first, verifies fresh boot-timing evidence and running:healthy on both roles, and then updates Application aishl-production-rolling-web (w3mxfr6wzjnpg3hqaw3y0obq) and requires three consecutive matching /ping probes on production and DEV. Do not treat https://aishippinglabs.com/ping alone as worker recovery proof; /ping only observes web.
After deploying the website canonical-host middleware and proxy routing rules, run the read-only redirect contract check:
uv run python deploy/verify_canonical_redirects.py https://aishippinglabs.comThe verifier disables automatic redirect following. It checks HTTP apex,
HTTP www, and HTTPS www for both / and a path with a query string,
requires an exact 301 to the clean apex HTTPS URL, and confirms the target
returns 200 without another redirect.
Production URL: https://aishippinglabs.com.
Agents should not treat local files, local SQLite, or an ad hoc database tunnel as production data. For live application records, use the authenticated production API with a staff token:
curl -sS -H "Authorization: Token $API_SHIPPING_LABS_API_TOKEN" \
https://aishippinglabs.com/api/users/alice@example.comUse the API for production user state, SES event history, email logs,
integration-setting metadata, and narrow operator writes that already
exist as API endpoints. The settings metadata endpoint uses the same Token
authentication:
curl -sS -H "Authorization: Token $API_SHIPPING_LABS_API_TOKEN" \
https://aishippinglabs.com/api/v1/settings/<key>Coolify runtime inspection is separate from application data. To inspect the
live source containers (web-mekt9kz74fvbixla6olinuma and
worker-mekt9kz74fvbixla6olinuma) and public /ping without printing
secrets:
uv run python deploy/coolify/verify_dual_runtime.pyFor remaining AWS infrastructure (S3/CloudFront CDN, SES/SNS email events,
RDS), inspect the DataTalksClub/aws-infra repo (AISL resources under
main/aisl/) and file/track infra issues there when changes are needed.
The RDS instance is in a private VPC and only accessible via the bastion host.
Add this to your ~/.ssh/config:
Host bastion-tunnel-aisl
HostName <BASTION_PUBLIC_IP>
User ubuntu
IdentityFile ~/.ssh/razer.pem
StrictHostKeyChecking no
LocalForward 5434 <RDS_ENDPOINT>:5432
Find the values from Terraform outputs or the AWS console:
BASTION_PUBLIC_IP— EC2 console, instance named "bastion"RDS_ENDPOINT— RDS console, instanceai-shipping-labs(orterraform output db_endpointinDataTalksClub/aws-infraundermain/aisl/)
- Open an SSH tunnel:
ssh -N bastion-tunnel-aisl- Connect with pgcli (in another terminal):
uvx pgcli -h localhost -p 5434 -U aisl_admin -d aisl_devEnter the DB password when prompted. Retrieve it from Secrets Manager:
aws secretsmanager get-secret-value \
--secret-id ai-shipping-labs/db-password \
--region eu-west-1 \
--query SecretString --output textThe RDS instance hosts two databases:
| Database | Environment |
|---|---|
aisl_dev |
Dev (dev.aishippinglabs.com) |
aisl_prod |
Production |
To connect to a specific database, pass it as the -d flag:
uvx pgcli -h localhost -p 5434 -U aisl_admin -d aisl_devThe bootstrap order on a fresh database is:
manage.py migrate # creates the slug+level skeleton tier rows
manage.py sync_content [--from-disk ...] # fills in name / prices / Stripe IDs / description / features
migrate seeds the four payments.Tier rows with only slug, level, and a placeholder name (the slug, title-cased). Every other editable tier field (price_eur_month, price_eur_year, stripe_price_id_monthly, stripe_price_id_yearly, description, features) is populated by the tiers.yaml content sync, which is the source of truth. Until sync_content runs against a yaml that carries those fields, the /membership page renders empty prices and empty feature lists; tier names still work via the placeholder, but the page is not customer-ready.
After creating a new database, run migrations first (if the container hasn't done it yet):
DB_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id ai-shipping-labs/db-password \
--region eu-west-1 \
--query SecretString --output text)
DATABASE_URL="postgresql://aisl_admin:${DB_PASSWORD}@localhost:5434/aisl_prod" \
uv run python manage.py migrateThen seed the content sources (one-time, idempotent):
DB_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id ai-shipping-labs/db-password \
--region eu-west-1 \
--query SecretString --output text)
DATABASE_URL="postgresql://aisl_admin:${DB_PASSWORD}@localhost:5434/aisl_dev" \
uv run python manage.py seed_content_sourcesThis registers the GitHub content sources so the sync page (/studio/sync/) can pull content. Replace aisl_dev with aisl_prod for production. The first sync_content run after that populates the tier fields above from tiers.yaml.
With the bastion tunnel open, run Django management commands against the remote database:
DB_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id ai-shipping-labs/db-password \
--region eu-west-1 \
--query SecretString --output text)
DATABASE_URL="postgresql://aisl_admin:${DB_PASSWORD}@localhost:5434/aisl_dev" \
uv run python manage.py createsuperuser --email you@example.comOr via a Python script:
DB_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id ai-shipping-labs/db-password \
--region eu-west-1 \
--query SecretString --output text)
DATABASE_URL="postgresql://aisl_admin:${DB_PASSWORD}@localhost:5434/aisl_dev" \
uv run python -c "
import django, os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'website.settings')
django.setup()
from accounts.models import User
from payments.models import Tier
user = User.objects.create_user(email='you@example.com', password='yourpass')
user.is_staff = True
user.is_superuser = True
user.tier = Tier.objects.get(slug='premium')
user.email_verified = True
user.save()
print('Created', user.email)
"OAuth credentials for Google, GitHub, and Slack login are configured in Studio > Settings > Auth & Login. The full operator setup — including the provider-console steps, callback URLs, scopes, and per-provider foot-guns — is documented in _docs/configuration.md section 3.
Use this section only when you need a local-development shortcut that bypasses Studio. For any non-local environment (dev, prod, staging), use the Studio path documented in configuration.md.
For local development you can pre-populate socialaccount.SocialApp entries from environment variables:
- Register OAuth apps with each provider, using
http://localhost:8000/accounts/<provider>/login/callback/as the callback URL. The full provider-console instructions live inconfiguration.mdsection 3. - Add the client ID and secret pairs to your local
.env:GOOGLE_OAUTH_CLIENT_ID=your-client-id GOOGLE_OAUTH_CLIENT_SECRET=your-client-secret GITHUB_OAUTH_CLIENT_ID=your-client-id GITHUB_OAUTH_CLIENT_SECRET=your-client-secret - Run
uv run python manage.py seed_data— it picks up any*_OAUTH_CLIENT_ID/*_OAUTH_CLIENT_SECRETpairs from the environment and creates the corresponding Social Application entries in the database.
This is a one-shot convenience for local dev; the canonical path on any deployed environment is Studio > Settings > Auth & Login.
redirect_uri_mismatch(Google) — Add bothlocalhostand127.0.0.1callback URIs in Google Console. allauth uses the host from the browser request.The redirect_uri MUST match the registered callback URL(GitHub) — GitHub only allows one callback URL per app. Create separate apps for each environment.User has no field named 'username'— EnsureACCOUNT_USER_MODEL_USERNAME_FIELD = Noneis insettings.py.SocialApp matching query does not exist— No Social Application has been added for this provider. Add one via Studio > Settings > Auth & Login (any environment) or viaseed_data(local dev shortcut).Site matching query does not exist— Run:uv run python manage.py shell -c "from django.contrib.sites.models import Site; Site.objects.update_or_create(id=1, defaults={'domain': 'localhost:8000', 'name': 'AI Shipping Labs'})"- Google shows "App not verified" warning — Expected during development. Click Continue. For production, submit for Google verification.
- Login works but accounts not consolidated — Ensure
SOCIALACCOUNT_EMAIL_AUTHENTICATION = TrueandSOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = Truein settings.