Deploy do Django em Produção: Checklist Passo a Passo

Cansado de programar?

Conheça a melhor e mais completa formação de Python e Django e sinta-se um programador verdadeiramente competente. Além de Python e Django, você também vai aprender Banco de Dados, SQL, HTML, CSS, Javascript, Bootstrap e muito mais!

Quero aprender Python e Django de Verdade! Quero aprender!
Suporte

Tire suas dúvidas diretamente com o professor

Projetos práticos

Projetos práticos voltados para o mercado de trabalho

Prática profissional

Formação moderna com foco na prática profissional

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, um ALLOWED_HOSTS errado vira erro 400 (DisallowedHost).
  • Segredos (SECRET_KEY, senha do banco) vêm de variáveis de ambiente, nunca do Git.
  • SECURE_BROWSER_XSS_FILTER nã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! :rocket:

Vá Direto ao Assunto…

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: o startproject agora gera um MAILERS com o backend de console, e o check --deploy barra isso. No Django 5.2 esse erro não aparece.
  • O comando avalia as settings que estiverem carregadas. Se você separa settings/dev.py e settings/prod.py, rode python 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 collectstatic em todo deploy. Com o storage Manifest, um template que referencia um estático que não passou pelo collectstatic gera erro 500 (veja em Erros comuns).
  • O STORAGES substituiu o antigo STATICFILES_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? :thumbsup:

Que tal receber 30 dias de conteúdo direto na sua Caixa de Entrada?

Sua assinatura não pôde ser validada.
Você fez sua assinatura com sucesso.

Assine as PyDicas e receba 30 dias do melhor conteúdo Python na sua Caixa de Entrada: direto e sem enrolação!

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); o psycopg2 ainda 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]), com CONN_MAX_AGE obrigatoriamente em 0:
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 Post e 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 manda X-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, veio 301 para https://meusite.com.br/admin/login/). Só use se o proxy sempre define esse cabeçalho, senão um cliente pode forjá-lo.
  • SESSION_COOKIE_SECURE e CSRF_COOKIE_SECURE: os cookies só trafegam por HTTPS.
  • CSRF_TRUSTED_ORIGINS: origens (com https://) 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: DEBUG desligado e SECRET_KEY que quebra se a variável faltar.
  • collectstatic e migrate fazem parte de todo deploy.
  • Atrás de proxy, SECURE_PROXY_SSL_HEADER evita 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 --check e o check --deploy no seu CI

Se ficou com alguma dúvida, fique à vontade para deixar um comentário no box aqui embaixo! Será um prazer te responder! :wink:

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.

Começe agora sua Jornada na Programação!

Não deixe para amanhã o sucesso que você pode começar a construir hoje!

#newsletter Olá :wave: Curtiu o artigo? Então faça parte da nossa Newsletter! Privacidade Não se preocupe, respeitamos sua privacidade. Você pode se descadastrar a qualquer momento.