Resposta rápida
Para colocar o Django em produção: rode manage.py check --deploy, desligue o DEBUG, preencha o ALLOWED_HOSTS, leia a SECRET_KEY de variável de ambiente, use PostgreSQL, rode collectstatic (com WhiteNoise) e migrate a cada deploy e sirva com Gunicorn (WSGI) ou Uvicorn (ASGI) atrás de HTTPS.
1
2
3
4
python manage.py check --deploy # zero erros antes de subir
python manage.py collectstatic --noinput
python manage.py migrate --noinput
gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3
Resumo em 30 segundos:
-
runserveré só para desenvolvimento: em produção use Gunicorn ou Uvicorn. - Com
DEBUG = False, umALLOWED_HOSTSerrado vira erro 400 (DisallowedHost). - Segredos (
SECRET_KEY, senha do banco) vêm de variáveis de ambiente, nunca do Git. -
SECURE_BROWSER_XSS_FILTERnão existe mais desde o Django 4.0: não copie de tutoriais antigos. - Django 6.1 é a versão estável; Django 5.2 é a LTS (suporte até abril de 2028).
Salve salve Pythonista!
O projeto roda liso no runserver, mas é só colocar no ar que aparecem o erro 400, o CSS do admin que sumiu, o relation does not exist e o redirecionamento em loop.
Este post é o checklist de deploy de Django, na ordem em que as coisas quebram. Todos os comandos foram executados num projeto de teste com Django 6.1.1 (Python 3.13), Gunicorn 26.2.0, Uvicorn 0.54.0, uvicorn-worker 0.4.0, WhiteNoise 6.12.0, psycopg 3.3.6 e PostgreSQL 17, e as saídas que você vai ver são as reais. Quase tudo vale também para o Django 5.2 LTS; as exceções (o MAILERS de e-mail e a CSP nativa) estão indicadas no caminho.
Se você ainda não tem um projeto, comece pelo seu primeiro projeto Django em 15 minutos e volte aqui. Para a visão geral do framework, temos o guia completo de Django.
Então… Bora pro post! ![]()
Vá Direto ao Assunto…
- Qual versão do Django usar em produção?
- O checklist de deploy do Django
- Passo 1: rode o
manage.py check --deploy - DEBUG=False e ALLOWED_HOSTS
- SECRET_KEY por variável de ambiente
- Arquivos estáticos: collectstatic e WhiteNoise
- PostgreSQL em produção
- Migrações no deploy
- Gunicorn (WSGI) e Uvicorn (ASGI): como subir o servidor
- HTTPS e as configurações SECURE_*
- E-mail de produção e o MAILERS do Django 6.1
- Logging em produção
- O script de deploy completo
- Onde hospedar o Django
- Erros comuns
- Exercícios resolvidos
Qual versão do Django usar em produção?
Antes de configurar qualquer coisa, escolha a versão. Segundo a página oficial de download do Django, em setembro de 2026 o cenário é este:
| Versão | Situação | Suporte de segurança até | Python suportado |
|---|---|---|---|
| Django 6.1 | Estável mais recente | Dezembro de 2027 | 3.12, 3.13, 3.14 |
| Django 6.0 | Só correções de segurança | Abril de 2027 | 3.12, 3.13, 3.14 |
| Django 5.2 LTS | Suporte longo (LTS) | Abril de 2028 | 3.10 a 3.14 |
A regra prática: projeto novo começa no 6.1. Se o servidor ainda roda Python 3.10 ou 3.11, ou se você prefere atualizar o Django só a cada dois ou três anos, fique no 5.2 LTS. A próxima LTS, o Django 6.2, está prevista para abril de 2027.
Fixe a versão no requirements.txt para que o servidor instale exatamente o que você testou:
1
2
3
4
5
6
Django==6.1.1
gunicorn==26.2.0
uvicorn==0.54.0
uvicorn-worker==0.4.0
whitenoise==6.12.0
psycopg[binary,pool]==3.3.6
E, claro, instale tudo dentro de um ambiente virtual, tanto na sua máquina quanto no servidor.
O checklist de deploy do Django
Esta é a lista completa. Cada item tem uma seção própria logo abaixo, com o código e a saída real.
| # | Item | Onde | Por que importa |
|---|---|---|---|
| 1 |
check --deploy sem erros |
Terminal | Pega a maioria dos esquecimentos de segurança |
| 2 | DEBUG = False |
settings.py |
Com DEBUG ligado, qualquer erro expõe código e configurações |
| 3 |
ALLOWED_HOSTS preenchido |
settings.py |
Sem ele, o Django recusa tudo com 400 |
| 4 |
SECRET_KEY em variável de ambiente |
Hospedagem | Assina sessões, cookies e tokens de senha |
| 5 |
collectstatic + WhiteNoise |
Deploy | Sem isso o CSS e o JS somem |
| 6 | PostgreSQL | DATABASES |
Banco concorrente, com backup e fora do disco da app |
| 7 |
migrate a cada deploy |
Deploy | Código novo com tabela velha quebra em produção |
| 8 | Gunicorn ou Uvicorn | Comando de start |
runserver não foi feito para produção |
| 9 | HTTPS + SECURE_* + cookies seguros |
settings.py e proxy |
Protege login, sessão e CSRF |
| 10 | Logging e e-mail de erro | settings.py |
Você precisa saber quando algo quebra |
A referência oficial é a deployment checklist da documentação do Django. Vale ler uma vez inteira.
Passo 1: rode o manage.py check --deploy
O check --deploy é o primeiro comando do checklist porque ele aponta sozinho quase tudo que está errado. Criei um projeto novo com django-admin startproject config ., sem mudar nada, e rodei:
1
python manage.py check --deploy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SystemCheckError: System check identified some issues:
ERRORS:
?: (mail.E001) Your MAILERS setting uses a development-only email backend in the 'default' entry (django.core.mail.backends.console.EmailBackend).
HINT: Use a production-ready email backend, such as the SMTP backend, otherwise email will not be sent.
WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. If your entire site is served only over SSL, you may want to consider setting a value and enabling HTTP Strict Transport Security. Be sure to read the documentation first; enabling HSTS carelessly can cause serious, irreversible problems.
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. Unless your site should be available over both SSL and non-SSL connections, you may want to either set this setting True or configure a load balancer or reverse-proxy server to redirect all connections to HTTPS.
?: (security.W009) Your SECRET_KEY has less than 50 characters, less than 5 unique characters, or it's prefixed with 'django-insecure-' indicating that it was generated automatically by Django. Please generate a long and random value, otherwise many of Django's security-critical features will be vulnerable to attack.
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. Using a secure-only session cookie makes it more difficult for network traffic sniffers to hijack user sessions.
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. Using a secure-only CSRF cookie makes it more difficult for network traffic sniffers to steal the CSRF token.
?: (security.W018) You should not have DEBUG set to True in deployment.
?: (security.W020) ALLOWED_HOSTS must not be empty in deployment.
System check identified 8 issues (0 silenced).
São 8 problemas num projeto recém-criado, e é normal: o settings.py gerado pelo startproject é feito para desenvolvimento. O mapa de cada código para a seção que resolve:
| Código | Problema | Onde resolver neste post |
|---|---|---|
security.W018 |
DEBUG ligado |
DEBUG=False e ALLOWED_HOSTS |
security.W020 |
ALLOWED_HOSTS vazio |
DEBUG=False e ALLOWED_HOSTS |
security.W009 |
Chave django-insecure-...
|
SECRET_KEY por variável de ambiente |
security.W008, W004
|
Sem redirecionamento HTTPS e sem HSTS | HTTPS e as configurações SECURE_* |
security.W012, W016
|
Cookies de sessão e CSRF sem Secure
|
HTTPS e as configurações SECURE_* |
mail.E001 |
Backend de e-mail de console | E-mail de produção e o MAILERS do Django 6.1 |
Duas observações importantes:
- O
mail.E001é novidade do Django 6.1: ostartprojectagora gera umMAILERScom o backend de console, e ocheck --deploybarra isso. No Django 5.2 esse erro não aparece. - O comando avalia as settings que estiverem carregadas. Se você separa
settings/dev.pyesettings/prod.py, rodepython manage.py check --deploy --settings=config.settings.prod, senão está checando o arquivo errado.
DEBUG=False e ALLOWED_HOSTS
Com DEBUG = True, qualquer exceção mostra ao visitante uma página com trechos do seu código, variáveis locais e boa parte das settings. A documentação é direta: nunca ligue o debug em produção. Leia o valor do ambiente e deixe o padrão desligado, assim um esquecimento resulta em site seguro, não em site exposto:
1
2
3
4
import os
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
O ALLOWED_HOSTS é a lista de domínios que o Django aceita no cabeçalho Host. Ele existe para impedir ataques de cabeçalho Host falso (por exemplo, envenenar o link do e-mail de “esqueci minha senha”). Com DEBUG = False e a lista vazia, nem o runserver sobe:
1
CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False.
Repare no if h da compreensão de lista: sem ele, uma variável vazia vira [""], que não é uma lista vazia e engana a checagem. No servidor, a variável fica assim:
1
export DJANGO_ALLOWED_HOSTS="meusite.com.br,www.meusite.com.br"
Um valor começando com ponto, como .meusite.com.br, aceita o domínio e todos os subdomínios. Evite "*": ele desliga a proteção.
SECRET_KEY por variável de ambiente
A SECRET_KEY assina cookies de sessão, tokens de redefinição de senha e mensagens. Quem tem a chave consegue forjar esses dados. Por isso ela precisa ser longa, aleatória, diferente da de desenvolvimento e ficar fora do repositório. Gere uma com a biblioteca secrets:
1
python -c "import secrets; print(secrets.token_urlsafe(50))"
O resultado tem 67 caracteres, bem acima do mínimo de 50 que o check --deploy exige. Guarde o valor no painel de variáveis de ambiente (ou no gerenciador de segredos) da sua hospedagem e leia no settings.py com colchetes, não com .get():
1
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
Com colchetes, se a variável não existir o Django falha logo na inicialização:
1
KeyError: 'DJANGO_SECRET_KEY'
É o comportamento que você quer: falhar no deploy é melhor do que subir com uma chave padrão que está no GitHub. Faça o mesmo com a senha do banco.
Para trocar a chave sem derrubar as sessões, use o SECRET_KEY_FALLBACKS: a nova vai em SECRET_KEY e a antiga fica na lista por um tempo.
Arquivos estáticos: collectstatic e WhiteNoise
Em desenvolvimento, o runserver serve CSS, JS e imagens sozinho. Em produção, não: é por isso que o admin aparece “pelado” no primeiro deploy. A solução mais simples é o WhiteNoise, que deixa o próprio servidor da aplicação servir os estáticos com compressão e cache. Três mudanças no settings.py:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", # logo depois do SecurityMiddleware
"django.contrib.sessions.middleware.SessionMiddleware",
# ... resto igual
]
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
O STATIC_ROOT é a pasta onde o collectstatic junta os estáticos de todos os apps. Rodando no projeto de teste:
1
python manage.py collectstatic --noinput
1
130 static files copied to '/.../deploy-test/staticfiles', 390 post-processed.
O “post-processed” é o WhiteNoise gerando versões com hash no nome (base.6398cd16ee3d.css) e comprimidas (.gz). Conferi com curl: o arquivo com hash sai com Cache-Control: max-age=315360000, public, immutable, e o navegador só baixa de novo quando o conteúdo muda.
Dois cuidados:
- Rode o
collectstaticem todo deploy. Com o storageManifest, um template que referencia um estático que não passou pelocollectstaticgera erro 500 (veja em Erros comuns). - O
STORAGESsubstituiu o antigoSTATICFILES_STORAGE, que foi removido no Django 5.1. Tutoriais que ainda usam a configuração antiga não funcionam no 6.1.
Arquivos de mídia (uploads de usuários) ficam de fora: o WhiteNoise não serve MEDIA_ROOT. Use um storage de objetos (S3 e compatíveis) ou o proxy reverso, e nunca deixe o servidor executar um arquivo enviado.
Está curtindo esse conteúdo? ![]()
Que tal receber 30 dias de conteúdo direto na sua Caixa de Entrada?
PostgreSQL em produção
O SQLite grava num arquivo local, o que não combina com disco efêmero, várias instâncias da aplicação ou muita escrita simultânea. O padrão em produção é PostgreSQL. O passo a passo de instalação e do driver está no post como conectar o Django ao PostgreSQL. Aqui entra a configuração de produção, com a senha vinda do ambiente:
1
2
3
4
5
6
7
8
9
10
11
12
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "app"),
"USER": os.environ.get("POSTGRES_USER", "app"),
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
}
}
O que cada ajuste de produção faz:
-
CONN_MAX_AGE = 60: reaproveita a conexão por até 60 segundos, em vez de abrir uma nova a cada requisição. -
CONN_HEALTH_CHECKS = True: testa se a conexão ainda está viva antes de reaproveitá-la (útil quando o banco reinicia).
Duas restrições que valem conferir nas notas de banco de dados do Django:
- O Django 6.1 suporta PostgreSQL 15 ou superior (o 14 saiu da lista). O driver recomendado é o
psycopg(versão 3); opsycopg2ainda funciona, mas deve ser descontinuado no futuro. - Se for rodar em ASGI, a documentação manda desligar as conexões persistentes. Use então o pool nativo do psycopg (pacote
psycopg[pool]), comCONN_MAX_AGEobrigatoriamente em0:
1
2
DATABASES["default"]["CONN_MAX_AGE"] = 0
DATABASES["default"]["OPTIONS"] = {"pool": True}
Testei as duas combinações: com CONN_MAX_AGE = 0 o pool funcionou; esquecendo o CONN_MAX_AGE = 60 junto com o pool, a primeira consulta quebra com:
1
django.core.exceptions.ImproperlyConfigured: Pooling doesn't support persistent connections.
E, mais importante que qualquer setting: configure backup do banco antes de ter usuários reais.
Migrações no deploy
Todo deploy que muda um model precisa aplicar as migrações no banco de produção. O comando é o mesmo de sempre, com --noinput para não travar esperando confirmação:
1
python manage.py migrate --noinput
1
2
3
4
Operations to perform:
Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
Applying blog.0001_initial... OK
Dois comandos de checagem ajudam a não esquecer nada (os dois terminam com código de saída 1 quando há problema, o que faz um pipeline de CI falhar):
-
python manage.py makemigrations --check --dry-run: acusa se algum model mudou e a migração não foi criada. Rode no CI, antes do deploy. No teste, ele listou+ Create model Poste saiu com código 1. -
python manage.py migrate --check: acusa se existem migrações não aplicadas no banco. Útil como verificação logo depois do deploy.
As migrações são arquivos de código: gere com makemigrations na sua máquina, faça commit e deixe o servidor só aplicar com migrate. Nunca rode makemigrations em produção. Se quiser revisar os dois comandos com calma, temos posts sobre o comando makemigrations e o comando migrate.
Gunicorn (WSGI) e Uvicorn (ASGI): como subir o servidor
O runserver não passou por auditoria de segurança nem de desempenho. Em produção, o Django roda num servidor WSGI ou ASGI, e o startproject já gera os dois pontos de entrada: config/wsgi.py e config/asgi.py.
Gunicorn com WSGI
Para projetos síncronos (a grande maioria), o Gunicorn é o padrão:
1
gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3
1
2
3
4
5
6
[2026-09-27 16:43:52 -0300] [4174864] [INFO] Starting gunicorn 26.2.0
[2026-09-27 16:43:52 -0300] [4174864] [INFO] Listening at: http://0.0.0.0:8000 (4174864)
[2026-09-27 16:43:52 -0300] [4174864] [INFO] Using worker: sync
[2026-09-27 16:43:52 -0300] [4174866] [INFO] Booting worker with pid: 4174866
[2026-09-27 16:43:52 -0300] [4174867] [INFO] Booting worker with pid: 4174867
[2026-09-27 16:43:52 -0300] [4174868] [INFO] Booting worker with pid: 4174868
Cada worker é um processo que atende uma requisição por vez. A página de design do Gunicorn sugere começar com (2 x núcleos de CPU) + 1 workers e ajustar sob carga (ela lembra que 4 a 12 workers costumam dar conta de muito tráfego). Troque config pelo nome da pasta onde está o seu wsgi.py. Se houver um Nginx na mesma máquina, use --bind 127.0.0.1:8000 para a porta não ficar exposta.
Uvicorn com ASGI
Se o projeto tem views async def, conexões longas ou WebSockets, use ASGI. Seguindo a página de deploy com Uvicorn da documentação do Django, há duas formas, e testei ambas:
1
2
3
4
5
# Uvicorn sozinho, com 2 processos
uvicorn config.asgi:application --host 0.0.0.0 --port 8000 --workers 2
# Gunicorn gerenciando workers do Uvicorn (pacote uvicorn-worker)
gunicorn config.asgi:application -k uvicorn_worker.UvicornWorker --bind 0.0.0.0:8000 --workers 2
1
2
3
4
5
6
7
8
9
[2026-09-27 16:43:56 -0300] [4174976] [INFO] Starting gunicorn 26.2.0
[2026-09-27 16:43:56 -0300] [4174976] [INFO] Listening at: http://0.0.0.0:8000 (4174976)
[2026-09-27 16:43:56 -0300] [4174976] [INFO] Using worker: uvicorn_worker.UvicornWorker
[2026-09-27 16:43:56 -0300] [4174978] [INFO] Booting worker with pid: 4174978
[2026-09-27 16:43:56 -0300] [4174980] [INFO] Booting worker with pid: 4174980
[2026-09-27 16:43:57 -0300] [4174978] [INFO] Started server process [4174978]
[2026-09-27 16:43:57 -0300] [4174978] [INFO] Waiting for application startup.
[2026-09-27 16:43:57 -0300] [4174978] [INFO] ASGI 'lifespan' protocol appears unsupported.
[2026-09-27 16:43:57 -0300] [4174978] [INFO] Application startup complete.
Acima está o início do log da segunda forma; o segundo worker repete as mesmas quatro últimas linhas. A linha ASGI 'lifespan' protocol appears unsupported é informativa: o Django não implementa o evento de lifespan e isso não é erro. O módulo uvicorn.workers, citado em tutoriais antigos, está depreciado (importá-lo emite DeprecationWarning): o worker agora vive no pacote uvicorn-worker, por isso o caminho é uvicorn_worker.UvicornWorker.
Um detalhe que só apareceu rodando: sob ASGI, o WhiteNoise gera o aviso StreamingHttpResponse must consume synchronous iterators ao servir estáticos. Funciona, mas em projetos ASGI com muito tráfego vale servir os estáticos por CDN ou proxy reverso.
| Situação | Use | Comando base |
|---|---|---|
| Projeto síncrono (views normais, ORM, templates) | Gunicorn + WSGI | gunicorn config.wsgi:application |
Views async def, streaming, conexões longas |
Uvicorn + ASGI | uvicorn config.asgi:application |
| ASGI com gerenciamento de processos do Gunicorn | Gunicorn + uvicorn-worker | gunicorn config.asgi:application -k uvicorn_worker.UvicornWorker |
| Desenvolvimento local | runserver | python manage.py runserver |
HTTPS e as configurações SECURE_*
Site com login precisa de HTTPS em todas as páginas, porque o cookie de sessão vale para o site inteiro. Quem cuida do certificado costuma ser a hospedagem ou um proxy reverso (Nginx, Caddy, load balancer), e o Django precisa saber disso. Este bloco zerou os avisos de segurança do check --deploy:
1
2
3
4
5
6
7
8
9
10
11
12
# HTTPS e cookies
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ["https://meusite.com.br"]
SECURE_HSTS_SECONDS = 3600 # comece baixo; depois de validar, suba para 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = False
# W021: só ative SECURE_HSTS_PRELOAD quando for inscrever o domínio na lista de preload
SILENCED_SYSTEM_CHECKS = ["security.W021"]
O que cada um faz:
-
SECURE_SSL_REDIRECT: redireciona (301) todo acesso HTTP para HTTPS. -
SECURE_PROXY_SSL_HEADER: quando o proxy mandaX-Forwarded-Proto: https, o Django entende que a requisição original era HTTPS. Sem isso, ele acha que tudo é HTTP e redireciona em loop (no teste, sem o cabeçalho, veio301parahttps://meusite.com.br/admin/login/). Só use se o proxy sempre define esse cabeçalho, senão um cliente pode forjá-lo. -
SESSION_COOKIE_SECUREeCSRF_COOKIE_SECURE: os cookies só trafegam por HTTPS. -
CSRF_TRUSTED_ORIGINS: origens (comhttps://) autorizadas a enviar POST. Necessário para subdomínios e para quando o proxy não repassa o esquema. -
SECURE_HSTS_SECONDS: manda o navegador usar só HTTPS no domínio pelo tempo definido. Comece baixo (se o certificado falhar, os visitantes ficam presos no erro até o prazo acabar) e, validado, suba para um ano (31536000).
Com essa configuração, o check --deploy terminou assim:
1
System check identified no issues (1 silenced).
O aviso silenciado é o security.W021, que pede SECURE_HSTS_PRELOAD = True. Preload inscreve o domínio numa lista embutida nos navegadores e é difícil de desfazer: é uma decisão à parte, não um item obrigatório.
Outras proteções já vêm ligadas por padrão. Os cabeçalhos de uma resposta real do admin:
1
2
3
4
5
X-Frame-Options: DENY
Strict-Transport-Security: max-age=3600; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Eles vêm dos padrões de SECURE_CONTENT_TYPE_NOSNIFF, SECURE_REFERRER_POLICY e SECURE_CROSS_ORIGIN_OPENER_POLICY e do XFrameOptionsMiddleware.
E o SECURE_BROWSER_XSS_FILTER? Muito tutorial ainda manda usar SECURE_BROWSER_XSS_FILTER = True, mas essa configuração foi removida no Django 4.0, junto com o cabeçalho X-XSS-Protection, que os navegadores modernos ignoram (notas do Django 4.0). Se ela está no seu arquivo, apague. A proteção moderna contra XSS é uma Content Security Policy, que o Django suporta nativamente desde a versão 6.0:
1
2
3
4
5
6
7
8
from django.utils.csp import CSP
MIDDLEWARE.append("django.middleware.csp.ContentSecurityPolicyMiddleware")
SECURE_CSP = {
"default-src": [CSP.SELF],
"img-src": [CSP.SELF, "data:"],
}
No teste, a resposta passou a incluir Content-Security-Policy: default-src 'self'; img-src 'self' data:. Aperte a política aos poucos, porque uma CSP restritiva demais quebra analytics e CDNs (referência de CSP do Django).
Se você usa Nginx na frente, o bloco que repassa para o Gunicorn precisa enviar o Host e o esquema original (sintaxe validada com nginx -t; o listen 443 ssl e o certificado ficam no mesmo bloco server, normalmente gerados pelo Certbot):
1
2
3
4
5
6
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
E-mail de produção e o MAILERS do Django 6.1
Se o site manda e-mail (redefinição de senha, avisos de erro), o backend precisa ser real. No Django 6.1, a configuração nova é o MAILERS, que substitui os antigos EMAIL_BACKEND, EMAIL_HOST e companhia (eles ainda funcionam, mas emitem aviso de depreciação). Foi isso que resolveu o mail.E001:
1
2
3
4
5
6
7
8
9
10
11
12
13
MAILERS = {
"default": {
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
"OPTIONS": {
"host": os.environ.get("EMAIL_HOST", "smtp.example.com"),
"use_tls": True,
"username": os.environ.get("EMAIL_USER", ""),
"password": os.environ.get("EMAIL_PASSWORD", ""),
},
},
}
DEFAULT_FROM_EMAIL = "[email protected]"
SERVER_EMAIL = "[email protected]"
Defina também DEFAULT_FROM_EMAIL e SERVER_EMAIL: o padrão é webmaster@localhost, que muitos provedores rejeitam. No Django 5.2 LTS, use as settings EMAIL_* clássicas. A referência é o guia de envio de e-mail do Django.
Chegar a um checklist de deploy como esse pressupõe um projeto Django rodando de verdade por trás dele. Se esse projeto ainda não existe, a Jornada Python ensina a construí-lo com Django, do zero às APIs REST:
Logging em produção
Com DEBUG = False, os erros saem da tela e precisam ir para algum lugar. O mais simples é a saída padrão, que o painel da hospedagem ou o journalctl coletam:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simples": {"format": "{asctime} {levelname} {name} {message}", "style": "{"},
},
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "simples"},
},
"root": {"handlers": ["console"], "level": "WARNING"},
"loggers": {
"django": {
"handlers": ["console"],
"level": os.environ.get("DJANGO_LOG_LEVEL", "INFO"),
"propagate": False,
},
},
}
Com ela, um erro 500 e uma tentativa com Host inválido apareceram assim no log do Gunicorn (encurtei os tracebacks):
1
2
2026-09-27 16:35:44,318 ERROR django.request Internal Server Error: /admin/login/
2026-09-27 16:35:44,330 ERROR django.security.DisallowedHost Invalid HTTP_HOST header: 'evil.com'. You may need to add 'evil.com' to ALLOWED_HOSTS.
O ADMINS faz o Django mandar e-mail a cada erro 500, mas a documentação lembra que isso não escala: com tráfego, use um serviço de monitoramento de erros. A referência é a documentação de logging do Django.
O script de deploy completo
Juntando tudo, este é o script que roda a cada deploy (muitas hospedagens chamam isso de “build command” ou “release command”):
1
2
3
4
5
6
7
#!/usr/bin/env bash
set -euo pipefail
python -m pip install -r requirements.txt
python manage.py check --deploy --fail-level WARNING
python manage.py collectstatic --noinput
python manage.py migrate --noinput
O set -euo pipefail para o script no primeiro erro, e o --fail-level WARNING faz o check --deploy barrar o deploy até por aviso (sem ele, só erros barram). Antes de silenciar o W021, o script parou na checagem, como deveria. Depois, a saída foi:
1
2
3
4
5
6
7
System check identified no issues (1 silenced).
0 static files copied to '/.../deploy-test/staticfiles', 130 unmodified, 359 post-processed.
Operations to perform:
Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
No migrations to apply.
Por fim, o comando de start do servidor (o Gunicorn ou o Uvicorn da seção anterior) fica fora do script, no “start command” da hospedagem, no Procfile, no serviço do systemd ou no CMD do Dockerfile.
Onde hospedar o Django
Não existe hospedagem “certa”: existe a que combina com quanto de servidor você quer administrar. As categorias, com alguns exemplos conhecidos (a lista não é recomendação nem ranking, e os planos mudam com frequência, então confira a documentação de cada um):
| Tipo | Exemplos | Você cuida de | Bom para |
|---|---|---|---|
| PaaS (plataforma gerenciada) | PythonAnywhere, Render, Railway, Fly.io | Código, variáveis de ambiente, comando de start | Primeiro deploy, projetos pessoais e MVPs |
| VPS (servidor virtual) | DigitalOcean, Hetzner, Linode, Amazon Lightsail | Sistema operacional, Nginx, systemd, certificado, atualizações | Quem quer controle total e aprender infraestrutura |
| Containers gerenciados | Google Cloud Run, AWS ECS com Fargate, Azure Container Apps | Dockerfile, imagem e configuração do serviço | Times que já usam Docker e querem escalar |
O checklist deste post vale para as três: o que muda é quem executa cada passo. No PaaS, você preenche variáveis num painel e aponta o build e o start; numa VPS, você mesmo instala PostgreSQL, Nginx e Certbot e cria um serviço do systemd para o Gunicorn.
Erros comuns
Estes são os erros que mais aparecem no primeiro deploy. Todos foram reproduzidos no projeto de teste, com a linha final real do log.
Erro 400 Bad Request (DisallowedHost)
O site responde 400 para todo mundo. No log:
1
django.core.exceptions.DisallowedHost: Invalid HTTP_HOST header: 'evil.com'. You may need to add 'evil.com' to ALLOWED_HOSTS.
Correção: coloque em ALLOWED_HOSTS o domínio como o visitante digita, incluindo o www. Se o log mostra domínios estranhos, são robôs, e o 400 está certo.
CSS do admin sumiu ou erro 500 com Missing staticfiles manifest entry
Com o storage Manifest do WhiteNoise e sem rodar o collectstatic, qualquer página que usa {% static %} quebra:
1
ValueError: Missing staticfiles manifest entry for 'admin/css/base.css'
Correção: rode python manage.py collectstatic --noinput em todo deploy e confira se o STATIC_ROOT está definido.
Tabela não existe (ProgrammingError)
O código novo subiu, mas as migrações não foram aplicadas no banco de produção:
1
django.db.utils.ProgrammingError: relation "blog_post" does not exist
Correção: inclua python manage.py migrate --noinput no script de deploy, antes de o servidor novo começar a atender.
Driver do PostgreSQL ausente (ImproperlyConfigured)
O requirements.txt do servidor não tem o driver:
1
django.core.exceptions.ImproperlyConfigured: Error loading psycopg2 or psycopg module
Correção: adicione psycopg[binary] (ou psycopg[binary,pool]) ao requirements.txt e refaça o deploy.
Erro 403 no login: Origin checking failed
O formulário de login (ou qualquer POST) é recusado atrás de um proxy HTTPS. No log:
1
Forbidden (Origin checking failed - https://www.meusite.com.br does not match any trusted origins.): /admin/login/
No teste, o erro aparecia quando o proxy não mandava X-Forwarded-Proto e sumia quando mandava. Correção: garanta que o proxy envia esse cabeçalho (veja o bloco do Nginx) e, para domínios extras, adicione a origem com https:// em CSRF_TRUSTED_ORIGINS.
Exercícios resolvidos
Tente responder antes de abrir a solução. Os códigos foram executados com Django 6.1.1 e Python 3.13.
Exercício 1. A variável de ambiente DJANGO_ALLOWED_HOSTS chegou como "meusite.com.br,,www.meusite.com.br," (com vírgulas sobrando). Escreva a linha do settings.py que gera uma lista limpa, sem strings vazias.
Ver solução
1
2
3
4
5
6
7
8
import os
os.environ["DJANGO_ALLOWED_HOSTS"] = "meusite.com.br,,www.meusite.com.br,"
ALLOWED_HOSTS = [
h.strip() for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h.strip()
]
print(ALLOWED_HOSTS)
Saída: ['meusite.com.br', 'www.meusite.com.br']
O split(",") gera strings vazias entre vírgulas seguidas, e o if h.strip() descarta essas entradas (e espaços acidentais).
Exercício 2. Escreva uma função env_bool(nome, padrao=False) que leia uma variável de ambiente e devolva True para "1", "true", "yes", "sim" ou "on" (sem diferenciar maiúsculas), e o padrão quando a variável não existir.
Ver solução
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os
def env_bool(nome, padrao=False):
valor = os.environ.get(nome)
if valor is None:
return padrao
return valor.strip().lower() in {"1", "true", "yes", "sim", "on"}
os.environ["DJANGO_DEBUG"] = "False"
print(env_bool("DJANGO_DEBUG"))
os.environ["DJANGO_DEBUG"] = "true"
print(env_bool("DJANGO_DEBUG"))
print(env_bool("VARIAVEL_QUE_NAO_EXISTE"))
Saída:
1
2
3
False
True
False
Variáveis de ambiente são sempre strings, e bool("False") é True, por isso é preciso comparar o texto em vez de converter direto.
Exercício 3. Gere uma SECRET_KEY com a biblioteca secrets e confira as três regras do aviso security.W009: pelo menos 50 caracteres, pelo menos 5 caracteres distintos e sem o prefixo django-insecure-.
Ver solução
1
2
3
4
import secrets
chave = secrets.token_urlsafe(50)
print(len(chave), len(set(chave)) >= 5, chave.startswith("django-insecure-"))
Saída: 67 True False
O token_urlsafe(50) usa 50 bytes aleatórios codificados em base64, o que dá 67 caracteres. As três condições do W009 ficam satisfeitas.
Exercício 4. O ALLOWED_HOSTS do seu projeto é ["meusite.com.br"]. Use a função validate_host do Django para descobrir se www.meusite.com.br é aceito e qual valor aceita os dois.
Ver solução
1
2
3
4
5
from django.http.request import validate_host
print(validate_host("meusite.com.br", ["meusite.com.br"]))
print(validate_host("www.meusite.com.br", ["meusite.com.br"]))
print(validate_host("www.meusite.com.br", [".meusite.com.br"]))
Saída:
1
2
3
True
False
True
Cada entrada casa com o domínio exato; o ponto inicial em .meusite.com.br libera o domínio e todos os subdomínios.
Exercício 5 (questão de prova). Um desenvolvedor copiou um tutorial antigo para o settings.py de um projeto em Django 6.1. Qual destas configurações não existe mais no Django atual?
a) SECURE_HSTS_SECONDS
b) SECURE_BROWSER_XSS_FILTER
c) SECURE_SSL_REDIRECT
d) SECURE_CONTENT_TYPE_NOSNIFF
Ver solução
1
2
3
4
5
from django.conf import global_settings
for nome in ["SECURE_HSTS_SECONDS", "SECURE_BROWSER_XSS_FILTER",
"SECURE_SSL_REDIRECT", "SECURE_CONTENT_TYPE_NOSNIFF"]:
print(nome, hasattr(global_settings, nome))
Saída:
1
2
3
4
SECURE_HSTS_SECONDS True
SECURE_BROWSER_XSS_FILTER False
SECURE_SSL_REDIRECT True
SECURE_CONTENT_TYPE_NOSNIFF True
Resposta: b. O SECURE_BROWSER_XSS_FILTER foi removido no Django 4.0; deixá-lo no arquivo não dá erro, simplesmente não faz nada. A proteção moderna é a CSP (SECURE_CSP).
Exercício 6 (questão de prova). Um site Django está com DEBUG = False e ALLOWED_HOSTS = ["meusite.com.br"]. Um visitante acessa https://www.meusite.com.br/. Qual é o código de status HTTP da resposta?
a) 200
b) 301
c) 400
d) 500
Ver solução
Saída (reproduzida com curl e um Host fora da lista): 400
Resposta: c. O Host www.meusite.com.br não está no ALLOWED_HOSTS (o Exercício 4 mostrou que a entrada casa só com o domínio exato), então o Django levanta DisallowedHost e responde 400 Bad Request. Para aceitar, adicione www.meusite.com.br ou use .meusite.com.br.
Quer construir projetos web com Django do zero, com suporte de um professor? Conheça o nosso curso de Python completo, a Jornada Python.
Conclusão
Neste checklist de deploy do Django, você viu:
✅ check --deploy - O primeiro e o último comando antes de subir
✅ DEBUG, ALLOWED_HOSTS e SECRET_KEY - Configuração por variável de ambiente
✅ WhiteNoise e collectstatic - Estáticos com compressão e cache longo
✅ PostgreSQL e migrações - Banco de produção e migrate a cada deploy
✅ Gunicorn e Uvicorn - WSGI ou ASGI, com os comandos testados
✅ HTTPS e SECURE_* - Sem o SECURE_BROWSER_XSS_FILTER, que não existe mais
Principais lições:
- Defaults seguros:
DEBUGdesligado eSECRET_KEYque quebra se a variável faltar. -
collectstaticemigratefazem parte de todo deploy. - Atrás de proxy,
SECURE_PROXY_SSL_HEADERevita o loop de redirecionamento. - HSTS começa com valor baixo.
Próximos passos:
- Revise os principais comandos do manage.py
- Configure backups do banco e um serviço de monitoramento de erros
- Coloque o
makemigrations --checke ocheck --deployno seu CI
Se ficou com alguma dúvida, fique à vontade para deixar um comentário no box aqui embaixo! Será um prazer te responder! ![]()
Perguntas frequentes
O que faz o comando manage.py check --deploy?
O python manage.py check --deploy roda o framework de checagens do Django incluindo as verificações específicas de produção: DEBUG ligado, ALLOWED_HOSTS vazio, SECRET_KEY fraca ou gerada pelo startproject, cookies sem Secure, HTTPS sem redirecionamento e HSTS ausente. No Django 6.1 ele também acusa backend de e-mail de desenvolvimento (mail.E001). Rode sempre com as settings de produção carregadas, senão ele avalia a configuração errada.
Por que o Django retorna 400 Bad Request depois que coloquei DEBUG=False?
Quase sempre é o ALLOWED_HOSTS. Com DEBUG = False, o Django só aceita requisições cujo cabeçalho Host esteja na lista ALLOWED_HOSTS e responde 400 para o resto, registrando DisallowedHost no log. Adicione o domínio exato (meusite.com.br) e também o www, ou use .meusite.com.br para aceitar o domínio e todos os subdomínios.
Preciso de Nginx se uso WhiteNoise no Django?
Não obrigatoriamente. O WhiteNoise faz o próprio Gunicorn ou Uvicorn servir os arquivos estáticos com compressão e cache longo, o que basta para a maioria dos projetos, principalmente em PaaS. Um proxy reverso como o Nginx continua útil para terminar o HTTPS, servir arquivos de mídia enviados por usuários e filtrar requisições antes do Django.
Gunicorn ou Uvicorn: qual usar para rodar o Django em produção?
Use Gunicorn com o arquivo wsgi.py se o projeto é síncrono, que é o caso da maioria. Use ASGI (Uvicorn sozinho ou Gunicorn com o worker uvicorn_worker.UvicornWorker) se você tem views assíncronas, conexões longas ou WebSockets. O runserver nunca deve ser usado em produção.
Posso usar SQLite em produção no Django?
Pode em sites pequenos, com um único servidor e pouca escrita concorrente, mas o caminho padrão é PostgreSQL. O SQLite grava num arquivo local, o que não funciona em hospedagens com disco efêmero nem com várias instâncias da aplicação. O Django 6.1 suporta PostgreSQL 15 ou superior, com o driver psycopg 3.
Onde guardar a SECRET_KEY do Django em produção?
Em uma variável de ambiente ou em um gerenciador de segredos da sua hospedagem, nunca no repositório. No settings.py, leia com SECRET_KEY = os.environ['DJANGO_SECRET_KEY']: se a variável faltar, o Django quebra na inicialização com KeyError, o que é melhor do que subir com uma chave padrão. Para trocar a chave sem derrubar sessões, use SECRET_KEY_FALLBACKS.
Qual versão do Django usar em produção em 2026?
Em setembro de 2026, o Django 6.1 é a versão estável mais recente (suporte até dezembro de 2027) e o Django 5.2 é a LTS, com correções de segurança até abril de 2028. Projetos novos podem começar no 6.1, que exige Python 3.12 ou superior. Quem precisa de estabilidade longa ou ainda roda Python 3.10 ou 3.11 fica no 5.2 LTS.
O SECURE_BROWSER_XSS_FILTER ainda existe no Django?
Não. O SECURE_BROWSER_XSS_FILTER foi removido no Django 4.0, junto com o cabeçalho X-XSS-Protection, que os navegadores modernos ignoram. Se ele ainda estiver no seu settings.py, apague a linha. A proteção equivalente hoje é uma Content Security Policy, que o Django configura nativamente desde a versão 6.0 com o SECURE_CSP.
"Porque o Senhor dá a sabedoria, e da sua boca vem a inteligência e o entendimento" Pv 2:6