This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
Django 5.2 on Python 3.11 locally, with a virtualenv in
venv/(not.venv). Production runs on Python 3.12, because theDockerfilestarts frompython:3.12(see Deployment). Avoid syntax or packages that work on only one of the two versions. -
Activate the venv directly instead of prefixing commands:
source venv/bin/activate. -
requirements.txtexists and is curated: it lists only direct dependencies with pinned versions. Never overwrite it withpip freeze(thevenv/also holds dev-only tooling that must not reach production); add new packages by hand with the installed version.Package Why it is there Django,asgiref,sqlparseThe framework and its dependencies. python-decoupleconfig()incore/settings.pyreadsSECRET_KEY,DEBUGand thePG*variables.python-dotenvload_dotenv()incore/settings.pyloads.envfor the variables read withos.getenv(logging, slow-request threshold).gunicornProduction WSGI server, started by the CMDof theDockerfile(see Deployment).whitenoiseServes static files from the Django process in production. psycopg2-binaryPostgres driver, used when DEBUG=False.python-json-loggerJSON log formatter used by core/logging_config.py.coverageTest coverage (configured in .coveragerc).psutilListed, but not imported by the project's own code. -
A
.envfile at the repository root is required:SECRET_KEYhas no default, so without it (or an exportedSECRET_KEYvariable) everymanage.pycommand fails with decouple'sUndefinedValueError..envis git-ignored; copy.env.exampleand fill in the values. Generate a key withpython -c "from django.core.management.utils import get_random_secret_key as g; print(g())".
Two readers coexist in core/settings.py: decouple.config() (for SECRET_KEY, DEBUG, PG*) and load_dotenv() + os.getenv (for everything else). Both read the same .env, and a real environment variable wins over the file in both.
| Variable | Read with | Default | Purpose |
|---|---|---|---|
SECRET_KEY |
config |
none (required) | Django secret key. Use a different value in production. |
DEBUG |
config (cast to bool) |
False |
Switches the database and the static files storage (see Architecture). Local .env sets True. |
PGDATABASE, PGUSER, PGPASSWORD, PGHOST, PGPORT |
config |
none (required when DEBUG=False) |
Postgres connection. Not read at all when DEBUG=True, so keep them out of the local .env. |
LOG_DIR |
os.getenv |
observability/app-logs under the repo root |
Where django.log and error.log are written. The directory is created on startup. |
LOG_LEVEL |
os.getenv |
DEBUG |
Level for the console and file log handlers. |
SLOW_REQUEST_THRESHOLD |
os.getenv |
1000 |
Milliseconds above which a request is logged as slow_request. |
APP_HEALTH_URL |
not read by Django | — | Used only by the heartbeat container in observability/docker-compose.yml (default there: http://host.docker.internal:8000/, the home page, not /health/). |
PORT |
not read by Django | — | Injected by Railway. The CMD of the Dockerfile uses it for the gunicorn bind (0.0.0.0:${PORT:-8000}, so it falls back to 8000 when unset). It is expanded by the shell, so it only works while the start command runs through a shell (see Deployment). |
The ELASTIC_APM_* variables no longer exist: the Elastic APM agent was removed from the app (see Architecture). Nothing reads them, so delete them from any .env or Railway service that still has them.
source venv/bin/activate
pip install -r requirements.txt # install the pinned dependencies
cp .env.example .env # first time only; then set SECRET_KEY (DEBUG=True for local work)
python manage.py runserver # dev server at http://127.0.0.1:8000
python manage.py makemigrations # after model changes
python manage.py migrate
python manage.py startapp <name> # then add it to INSTALLED_APPS in core/settings.py
python manage.py test # all tests
python manage.py test <app>.tests.<TestCase>.<test_method> # a single test
python manage.py collectstatic --noinput # copies static files into staticfiles/ (git-ignored)To check the production path locally without a database connection:
DEBUG=False SECRET_KEY=x PGDATABASE=x PGUSER=x PGPASSWORD=x PGHOST=x PGPORT=5432 \
python manage.py check --deploy
gunicorn core.wsgi --check-configThere's no linter, formatter, or pytest setup configured.
core/is the project package. It holdssettings.py, the rooturls.py(routeshealth/,admin/and includesprodutos.urlsat''), andwsgi.py/asgi.py. It also has its own code:core/views.py:health_check(URL/health/, namehealth, GET only). It runsSELECT 1and returns{"status": "ok"}with 200, or{"status": "error", "detail": ...}with 503 when the database is unreachable.core/middleware.py:RequestTrackingMiddleware. It gives every request an ID (reusing an incomingX-Request-IDheader), logs one JSON line per request to thecorelogger (request_ok,slow_requestaboveSLOW_REQUEST_THRESHOLD, orrequest_errorfor 5xx), and adds theX-Request-IDandServer-Timingresponse headers.core/logging_config.py:get_logging_config()buildsLOGGINGwith a console handler, rotating JSON files (django.log,error.log, 10 MB with 5 backups) inLOG_DIR. Thejsonformatter is plainpythonjsonlogger.jsonlogger.JsonFormatter, so log lines carry notrace.idortransaction.id; therequest_idfield written by the middleware is the only way to correlate lines of one request.core/tests.py: tests for the middleware, the health check and the logging configuration.
- Middleware order in
core/settings.pymatters:core.middleware.RequestTrackingMiddlewarefirst (so its timing covers the whole request), thenSecurityMiddleware, thenwhitenoise.middleware.WhiteNoiseMiddleware(it must stay immediately afterSecurityMiddleware), then Django's defaults. observability/holds a local Elastic stack (docker-compose.ymlwith Elasticsearch, Kibana, APM server, Filebeat, Heartbeat and Metricbeat). It is not part of the Railway deploy.-
The app has no Elastic APM agent any more. It was removed (the
elasticapminstalled app, itsTracingMiddleware, theELASTIC_APMsettings dict, the logging handler and theelastic-apmpackage) because Railway has no APM server and the agent filled the log withelasticapm.transport Failed to submit message: Connection to APM Server timed out (http://localhost:8200). Think of the stack as a control room whose camera feed from inside the app was unplugged: the room still gets the app's written diary and still checks from outside whether the door opens. -
What the stack still receives from the app:
Source How What it gives Filebeat Reads /var/log/app/*.log, which isobservability/app-logs(the defaultLOG_DIR) mounted read-onlyThe JSON lines of django.loganderror.log, including the per-request lines ofRequestTrackingMiddleware.Heartbeat HTTP check on APP_HEALTH_URL, TCP check on port 8000 and ICMP ping ofAPP_HOSTUptime and response time. Metricbeat Host and Docker metrics CPU, memory and disk of the machine, not of the Django process. -
What it no longer receives: traces, spans, APM errors and the per-process metrics of the agent (
traces-apm*,logs-apm.error*,metrics-apm*). Theapm-serverandsetup-apmservices still exist in the compose file and still start, but nothing sends data to port 8200. -
Stale after the removal, and not yet updated:
observability/README.mdstill documents theELASTIC_APM_*variables, theTracingMiddleware, thetrace.idinjection in logs and the APM screens in Kibana; the agent.claude/agents/observability-analyst.mdstill queries thetraces-apm*,logs-apm.error*andmetrics-apm.internal*indices, which stay empty; the comment inobservability/filebeat/filebeat.ymlstill mentions thetrace.idandtransaction.idfields. Do not follow those parts.
-
produtos/is the only app. It serves the home page (produtos:home, URL/): a single function viewhomewith aProdutoForm(ModelForm) to register aProduto(nome, quantidade, criado_em) and a newest-first list on the same page. A valid POST saves, adds amessages.success, and redirects back to/(Post/Redirect/Get). Validation rules live on the model fields, and the form applies them. Tests are inprodutos/tests.py. Specs for the feature are inspecs/001-product-registry-home/.- New apps belong at the repository root next to
core/, and their URLs are wired in withinclude()incore/urls.py. - Templates:
TEMPLATES['DIRS']is empty andAPP_DIRS=True, so templates currently resolve only from<app>/templates/. - Static files:
STATIC_URL='static/'andSTATIC_ROOT=BASE_DIR / 'staticfiles'(thecollectstatictarget, git-ignored); there is noSTATICFILES_DIRS.STORAGES['staticfiles']depends onDEBUG: Django's plainStaticFilesStoragewhenDEBUG=True, andwhitenoise.storage.CompressedManifestStaticFilesStoragewhenDEBUG=False. The manifest storage is kept out of development and tests on purpose, because it makes every{% static %}fail untilcollectstatichas run. - Database: chosen by
DEBUGincore/settings.py. WithDEBUG=Trueit is SQLite atdb.sqlite3; withDEBUG=Falseit is Postgres, configured fromPGDATABASE,PGUSER,PGPASSWORD,PGHOSTandPGPORT, withCONN_MAX_AGE=600and aconnect_timeoutof 10 seconds. DEBUGis therefore the single switch between the development and production setups. It defaults toFalse, so an environment that forgets to set it gets the production setup and fails at startup if thePG*variables are missing.- Hosts:
ALLOWED_HOSTSislocalhost,127.0.0.1,host.docker.internaland.railway.app;CSRF_TRUSTED_ORIGINSishttps://*.railway.app. A custom domain has to be added to both. - The locale is pt-BR:
LANGUAGE_CODE='pt-br'andTIME_ZONE='America/Sao_Paulo', so Django's built-in validation messages appear in Portuguese.
The project is prepared to run on Railway. Locally it behaves as before (SQLite, runserver); on Railway the same code runs behind gunicorn, talks to Postgres and serves its own static files through WhiteNoise. The procedure that produced this setup is the skill .claude/skills/django-deploy; follow it when repeating or extending the deploy configuration.
In plain terms: Railway receives the repository, follows the recipe in the Dockerfile to build a sealed box (a container image) with Python and the dependencies inside, and then switches the box on. Switching it on runs one line written at the end of the same recipe: it prepares the database and only then opens the door to visitors. Railway's proxy knocks on that door from outside the box, so the door has to face outward; that is what the gunicorn bind is about. The Dockerfile is the only file that describes the deploy: there is no start script, no Procfile and no railway.json. One chore is currently missing from the recipe: nothing gathers the static files (collectstatic), see the warning below.
graph TD
A[Push to the repository] --> B[Railway finds Dockerfile at the root]
B --> C[Build: python:3.12, pip install -r requirements.txt, COPY . .]
C --> D[Container start: shell-form CMD, run by /bin/sh -c]
C -.->|missing today| S[collectstatic --noinput: runs nowhere]
D --> E[migrate --noinput]
E -->|fails| X[Container exits, deploy fails]
E -->|ok| G[exec gunicorn core.wsgi --bind 0.0.0.0:PORT]
G --> H[Railway proxy reaches the app]
R[Custom Start Command set on the Railway service] -.->|would replace the CMD, keep it empty| D
-
The
Dockerfileat the repository root is what Railway builds. Because it exists, Railway does not use its automatic builder.Step in DockerfileWhat it does FROM python:3.12Base image. This is where the production Python version is fixed (the local venv/is 3.11).WORKDIR /appThe code lives in /appinside the container.ENV PYTHONDONTWRITEBYTECODE=1,ENV PYTHONUNBUFFERED=1No .pycfiles, and unbuffered output so logs reach the Railway log viewer immediately.pip install --upgrade pip,apt-get install libpq-dev gccSystem packages for building Postgres clients. COPY requirements.txt .+pip install --no-cache-dir -r requirements.txtInstalls the curated dependencies; copied before the code so the layer is cached. COPY . .Copies the whole repository into the image. CMD python manage.py migrate --noinput && exec gunicorn core.wsgi --bind 0.0.0.0:${PORT:-8000}The start command of the container, in shell form. -
collectstaticdoes not run anywhere in the deploy right now (latent bug).-
The committed
Dockerfile(commitad3e658) goes straight fromCOPY . .to theCMD. The oldentrypoint.prod.shandProcfilewere the only places that rancollectstatic, and both were deleted.staticfiles/is git-ignored, so it is not in Railway's build context either: the image has no/app/staticfilesand nostaticfiles.jsonmanifest. -
Effect with
DEBUG=False:CompressedManifestStaticFilesStorageraisesValueError: Missing staticfiles manifest entryfor every{% static %}tag. No template ofprodutos/orcore/uses{% static %}, so/and/health/still answer, but the Django admin (/admin/) does use it and returns 500, and no file is served under/static/. -
The intended design, already described in the skill
.claude/skills/django-deploy(section 7) but not present in theDockerfile, is to run it at build time, betweenCOPY . .and theCMD:RUN DEBUG=False SECRET_KEY=build PGDATABASE=build PGUSER=build PGPASSWORD=build PGHOST=build PGPORT=5432 \ python manage.py collectstatic --noinputThe values are placeholders valid only for that one command (they are not
ENV):core/settings.pycannot be imported withoutSECRET_KEYand, withDEBUG=False, without thePG*variables.collectstaticonly imports the settings and never opens a database connection.DEBUG=Falseis needed so the manifest storage writes the hashed files and the manifest.
-
-
The
CMDis the start command, run on every start of the container:CMD python manage.py migrate --noinput && exec gunicorn core.wsgi --bind 0.0.0.0:${PORT:-8000}- It is written in shell form (no JSON brackets) on purpose: Docker runs it as
/bin/sh -c "...", and it is the shell that understands&&and expands${PORT:-8000}. The exec form (CMD ["python", ...]) would do neither. &&makes a failedmigratestop the line, so gunicorn does not start against a database that is not migrated; the container exits and the deploy fails.- The
--bind 0.0.0.0:${PORT:-8000}is mandatory. Without--bind, gunicorn listens on127.0.0.1:8000, which is reachable only from inside the container; Railway's proxy cannot connect and every request returns 502.$PORTis injected by Railway;8000is only the fallback for running the image elsewhere. execreplaces the shell with gunicorn, so gunicorn receives the stop signal directly and shuts down cleanly.
- It is written in shell form (no JSON brackets) on purpose: Docker runs it as
-
Do not combine the
Dockerfilewith aProcfile, and keep the "Custom Start Command" of the Railway service empty. Bothentrypoint.prod.shandProcfilewere deleted for this reason (see the incident below). If a start command on the Railway side is ever unavoidable, wrap it in a shell:/bin/sh -c "python manage.py migrate --noinput && exec gunicorn core.wsgi --bind 0.0.0.0:$PORT". -
Variables to set on the Railway app service:
Variable Value DEBUGFalseSECRET_KEYA production key, different from the local one. PGDATABASE,PGUSER,PGPASSWORD,PGHOST,PGPORTReferences to the Railway Postgres service, for example PGHOST=${{Postgres.PGHOST}}.
In plain terms: the box was switched on, did the first of its three chores and then went silent, so visitors got an error page. The suspicion is that Railway was reading the to-do list in a way that only understands its first item.
- Symptom. The Railway deploy log showed
migraterunning and then nothing: nocollectstaticoutput, no gunicorn boot lines, and every request answered with 502. It happened with several different versions ofentrypoint.prod.sh. - What existed then. The
Dockerfileended inCMD ["/app/entrypoint.prod.sh"], and the repository also had aProcfilewithweb: python manage.py migrate && python manage.py collectstatic --noinput && gunicorn core.wsgi --bind 0.0.0.0:$PORT. This document used to say theProcfilehad no effect while aDockerfileexisted; that statement is now in doubt. - Hypothesis (from Railway's documentation and forum; not yet confirmed by a successful deploy): on services built from a
Dockerfile, Railway takes the start command from theProcfile, fromrailway.jsonor from the service's "Custom Start Command" field and runs it in exec form, without a shell, replacing the image'sENTRYPOINT/CMD. In a chaina && b && conly the first program runs (the rest becomes its arguments) and$PORTis not expanded. That matches the symptom exactly: the first program of theProcfileline wasmigrate. - What was changed (commit
ad3e658).entrypoint.prod.shandProcfilewere deleted and the start became the shell-formCMDdescribed above, so the image no longer depends on anything outside theDockerfile. The plan also movedcollectstaticto the build, but thatRUNline is not in the committedDockerfile(see the latent bug above). - Still to do. Add the build-time
collectstaticto theDockerfile. Confirm with a deploy that the log now showsmigratefollowed by the gunicorn boot lines and that the site answers. Check in the Railway dashboard that the service's "Custom Start Command" is empty: it lives outside the repository, so deleting theProcfiledoes not clear it. If the symptom persists, the hypothesis is wrong and the investigation has to restart from the Railway service settings and deploy log. Update this section with the result either way.
Known gaps and gotchas (not handled in the code yet):
- No HTTPS hardening settings exist (
SECURE_SSL_REDIRECT,SECURE_HSTS_SECONDS,SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE,SECURE_PROXY_SSL_HEADER), socheck --deployreports security warnings. - There is no tracing or error tracking in production. The Elastic APM agent was removed, so on Railway the only signals are the console logs (with the
request_idand duration of each request) and/health/. IfFailed to submit message: Connection to APM Server timed outshows up in the Railway log again, the running image is older than the removal. - Logs are also written to files under
LOG_DIR(inside the container on Railway). Only the console handler output reaches the Railway log viewer. - A 502 right after a deploy means gunicorn is not listening on
0.0.0.0:$PORT, or is not running at all. Both already happened: versions of the now-deletedentrypoint.prod.shrangunicorn core.wsgiwith no--bindand rancollectstaticin the background with&, and later gunicorn never started (see the incident above). Do not remove the bind, do not putmigratein the background, and do not reintroduce aProcfileor a start script. - Python differs between environments: 3.12 in the image (
FROM python:3.12, a floating tag with no patch version) and 3.11 in the localvenv/. Tests run locally only, so they never exercise 3.12. There is noruntime.txt,.python-version,Procfile,railway.jsonornixpacks.toml; theDockerfileis the only place that fixes the version and the start command. - The start command now exists in one place in the repository (the
CMDof theDockerfile), but a "Custom Start Command" saved on the Railway service would still replace it without any trace in git. The skill.claude/skills/django-deploydescribes both paths: with aDockerfile(this repository, noProcfile) and without one (Procfile). - The fix for the Railway start incident is unverified: no deploy has confirmed it yet (see the incident above).
collectstaticis not executed in the build or at start, so production has no collected static files and/admin/fails (see the latent bug above). The skill.claude/skills/django-deployshows a build-timeRUN ... collectstaticthat theDockerfiledoes not contain. Once added, that line depends on a hand-written list of placeholder variables: it breaks the build as soon ascore/settings.pygains another required variable.- There is no
.dockerignore, soCOPY . .copies everything in the build context. On Railway the context is the repository, so git-ignored files are absent; a localdocker buildwould also copy.env,venv/,db.sqlite3,.git/and a stale localstaticfiles/into the image. migrateruns on every container start. That is fine with a single instance; with more than one replica, several containers would run migrations at the same time.- The image installs
libpq-devandgcc, althoughrequirements.txtusespsycopg2-binary, which ships its own compiled library. They only make the image larger. The container also runs as root, and theDockerfilehas noEXPOSE. - The old hardcoded
django-insecure-...SECRET_KEYwas removed fromcore/settings.pybut remains in the git history. Never reuse it. .github/workflows/contains only Claude Code workflows; nothing runs the test suite or deploys automatically from CI.
Para cada nova funcionalidade, siga obrigatoriamente a skill
.claude/skills/django-tdd — escreva os testes antes da
implementação (Red → Green → Refactor).
Cobertura mínima exigida por funcionalidade:
- Models — campos, validações, métodos,
__str__, constraints. - Forms — validação de campos,
clean_*, mensagens de erro. - Views — status codes, contexto, permissões, redirecionamentos.
- Templates — renderização, blocos, presença de elementos esperados.
- Integração — fluxo end-to-end cobrindo a jornada do usuário.
Só marque a funcionalidade como concluída depois que todos esses níveis de testes estiverem verdes.
Ao finalizar QUALQUER alteração de código neste repositório, é OBRIGATÓRIO executar,
como última etapa, o agente doc-sync-onboarding para atualizar a documentação
(CLAUDE.md e arquivos em docs/) refletindo as mudanças feitas.
Isso vale para toda e qualquer modificação: novos modelos/campos, migrations, views, rotas,
tasks assíncronas, signals, middlewares, integrações, variáveis de ambiente, scripts de
infraestrutura, etc. Nenhuma tarefa de código é considerada concluída antes de a
documentação ter sido sincronizada por esse agente.
O trabalho é dividido entre papéis, cada um com o modelo mais adequado à complexidade da tarefa. Ao delegar via Agent, escolha o papel pelo tipo de tarefa e passe o model correspondente.
Use para tudo que exige decisão, raciocínio ou código de produção.
- Decide quais agentes executarão cada tarefa e delega o restante aos papéis abaixo.
- Escreve as specs (
speckit-specify,speckit-plan,speckit-tasks) e as decisões de arquitetura. - Implementa o código de negócio: models, migrations, views, tasks Celery, integrações, pagamentos, segurança, performance.
- Revisa o resultado dos demais agentes antes de considerar a tarefa concluída.
- Escreve todos os tipos de teste: unitários, integração, e2e, de regressão, etc. (
pytest,pytest-django,factory_boy; ver skilldjango-tdd). - Recebe do tech-lead a spec/comportamento esperado e devolve testes executáveis; não altera código de produção (se achar um bug, reporta ao tech-lead).
Tarefas de texto e ajustes triviais:
- Escrever mensagens de commit (seguindo a atribuição definida nas instruções de commit).
- Escrever/atualizar tasks no Linear.
- Criar changelogs.
- Corrigir falhas banais de interface (typos, textos, espaçamento, classes Tailwind simples).
Tarefas não críticas e de baixa complexidade:
- Iniciar projetos (setup inicial,
uv sync,.env, migrations locais). - Rodar containers (
docker build/run, subir stack, Redis, worker Celery). - Corrigir falhas de interface simples.
- Qualquer outra tarefa rotineira de baixo risco. Se a tarefa se revelar crítica ou complexa, devolve ao tech-lead.
- Em caso de dúvida sobre a complexidade, suba um nível de modelo em vez de descer.
- Código que toca pagamentos (
payments), autenticação/assinaturas (accounts), segurança ou migrations de dados é sempre do tech-lead/desenvolvedor. - A sincronização de documentação (
doc-sync-onboarding) continua obrigatória como última etapa de qualquer alteração de código (ver seção acima).