Fazendo deploy de Django com Celery na Hetzner
Siga isso no seu próprio servidor. Comece grátis, sem cartão
Uma aplicação Django com jobs em segundo plano não é um processo, são pelo menos três: o gunicorn servindo requisições, um worker do Celery esvaziando a fila, e o Redis guardando essa fila entre os dois. A maioria dos tutoriais para no runserver e deixa o worker, o broker e o certificado HTTPS como exercício pra depois. Este guia constrói tudo isso num servidor Hetzner: gunicorn por trás do Caddy, um worker do Celery e um scheduler beat opcional, Postgres e Redis, tudo como serviços do Docker Compose com backups de verdade e um caminho de atualização de verdade. O caminho mais curto no final sobe a mesma aplicação com um git push no lugar.
Este guia usa Postgres 16 e Redis 8.
O que você vai precisar
- Um servidor Hetzner Cloud. Um CPX31 (4 vCPU, 8 GB) roda gunicorn, um worker do Celery, Postgres e Redis juntos sem fazer swap, por €62,99/mês sem IVA. Um CX23 menor (2 vCPU, 4 GB) por €5,99/mês sem IVA funciona pra uma aplicação de baixo tráfego com uma fila leve, só fica mais apertado quando os shared buffers do Postgres e os processos worker do Celery estão ambos residentes.
- Um nome de domínio, apontado pro IP do servidor antes da etapa de HTTPS.
- Uma conta Hetzner Cloud e um token de API (Security → API Tokens, Read & Write) se você quiser automatizar a criação do servidor; o Hetzner Cloud Console também funciona bem se você preferir clicar uma vez só.
Passo 1: criar o servidor
No Hetzner Cloud Console, crie um servidor no Ubuntu 24.04, no tamanho que você escolher, e numa região próxima dos seus usuários. Se preferir automatizar:
curl -X POST "https://api.hetzner.cloud/v1/servers" \
-H "Authorization: Bearer $HETZNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "django-app",
"server_type": "cpx31",
"image": "ubuntu-24.04",
"location": "nbg1",
"ssh_keys": ["your-ssh-key-name"]
}'
Você deve receber de volta uma resposta JSON com um server.id e um server.public_net.ipv4.ip. Entre via SSH com esse IP quando ele terminar de inicializar, geralmente em menos de um minuto:
ssh root@YOUR_SERVER_IP
Passo 2: instalar o Docker e apontar o DNS
curl -fsSL https://get.docker.com | sh
docker compose version
Você deve ver uma string de versão do Compose v2.x. Enquanto isso instala, crie o registro A de DNS pro seu domínio e confirme que ele resolve:
dig +short app.example.com
Ele precisa retornar o IP do servidor antes que o Caddy consiga emitir um certificado depois, então faça essa etapa logo no início e deixe propagar em segundo plano.
Passo 3: escrever a imagem do Django
O Django não traz uma imagem oficial do jeito que o Postgres ou o Redis trazem, já que as dependências de cada projeto são diferentes, então você constrói a sua própria:
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000"]
libpq-dev e gcc estão ali pro psycopg2 (o build não-binário); remova-os se o seu requirements.txt fixar psycopg2-binary no lugar, que já traz seu próprio libpq compilado. Rodar collectstatic no momento do build significa que um arquivo estático quebrado nunca chega ao tráfego de produção, já que o build falha antes da imagem ser enviada.
Passo 4: escrever o arquivo do Docker Compose
Cinco serviços: a aplicação Django como web, a mesma imagem de novo como worker e beat com comandos diferentes, Postgres, Redis, e Caddy na frente.
services:
web:
build: .
restart: unless-stopped
command: gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 3
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
worker:
build: .
restart: unless-stopped
command: celery -A myproject worker --loglevel=info --concurrency=2
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
beat:
build: .
restart: unless-stopped
command: celery -A myproject beat --loglevel=info
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myproject
POSTGRES_USER: myproject
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myproject"]
interval: 5s
start_period: 10s
retries: 20
networks:
- app_net
redis:
image: redis:8-alpine
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- app_net
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- web
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- app_net
networks:
app_net:
volumes:
postgres_data:
redis_data:
caddy_data:
caddy_config:
Remova o serviço beat por completo se você não agenda tasks periódicas. worker e beat são construídos a partir do exato mesmo Dockerfile que o web, esse é o ponto 12-factor do modelo de processos: um único código-base, três papéis, iniciados com três comandos diferentes em vez de três repositórios pra manter sincronizados.
Escreva o .env ao lado dele:
POSTGRES_PASSWORD=a-long-random-string
DJANGO_SETTINGS_MODULE=myproject.settings
SECRET_KEY=a-different-long-random-string
DEBUG=False
ALLOWED_HOSTS=app.example.com
DATABASE_URL=postgres://myproject:a-long-random-string@db:5432/myproject
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
SECRET_KEY e a senha do Postgres devem vir cada uma de openssl rand -hex 32, não algo digitado à mão.
Passo 5: rodar as migrations e subir o stack
docker compose up -d --build
docker compose run --rm web python manage.py migrate
Você deve ver a saída de migration do Django terminando numa lista de migrations aplicadas, não um erro sobre uma tabela faltando ou uma conexão recusada pro db. Depois cheque se o worker conectou no broker:
docker compose logs worker --tail=20
Você deve ver uma linha como celery@worker ready. confirmando que ele conectou ao Redis e está esperando por tasks.
Passo 6: escrever o Caddyfile
app.example.com {
reverse_proxy web:8000
handle_path /static/* {
root * /srv/static
file_server
}
}
O Caddy solicita e renova seu próprio certificado pelo Let’s Encrypt na primeira vez que consegue responder na porta 80 pra um domínio que já resolve pra esse servidor, sem precisar de uma etapa separada de certbot. Se você servir arquivos estáticos direto do Caddy em vez de pelo WhiteNoise, monte o mesmo diretório estático em que o container web escreve dentro do serviço caddy como /srv/static.
Passo 7: backups
O Postgres guarda os dados que realmente importam; faça backup dele com pg_dump numa agenda de cron em vez de confiar só no volume:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p backups
docker compose exec -T db pg_dump -U myproject myproject | gzip > "backups/django-db-$STAMP.sql.gz"
find backups -name '*.sql.gz' -mtime +14 -delete
Copie backups/ pra fora do servidor na mesma agenda, pra armazenamento de objetos ou outra máquina. Um dump que nunca saiu do servidor onde foi feito não protege contra o próprio servidor falhando.
Passo 8: atualizações
Puxe seu novo build de imagem, aplique as migrations, depois recrie os containers da aplicação um por vez:
docker compose build
docker compose run --rm web python manage.py migrate
docker compose up -d web worker beat
Rodar a migration como um run --rm pontual antes de tocar nos containers de longa duração significa que uma migration que falha para o deploy em vez de reiniciar parcialmente o web contra um schema que ele não espera.
O caminho mais curto: fazer isso com o Railyard
O inspetor de repositório do Railyard lê requirements.txt e manage.py pra detectar o Django por conta própria, sem precisar de Dockerfile ou Procfile. Ele define o comando de release como python manage.py migrate --no-input, inicia o web com gunicorn vinculado à porta atribuída, e roda o collectstatic na etapa de build automaticamente. Uma dependência psycopg/psycopg2 provisiona o Postgres e injeta DATABASE_URL; um cliente Redis (redis, django-redis, rq) faz o mesmo pro REDIS_URL. Se o Celery estiver nas suas dependências, ele é oferecido como processo worker por conta própria, e o Celery Beat também é adicionado se django-celery-beat estiver presente ou suas configurações definirem um beat schedule, então não há Procfile pra escrever à mão pra nenhum dos dois.
railyard create my-app https://github.com/me/my-app --addons postgres,redis
ou cole a mesma URL Git no dashboard depois que o GitHub estiver conectado. Escale o worker independentemente uma vez que estiver rodando:
railyard scale my-app worker=2
A Hetzner é um dos provedores em que o Railyard pode criar um servidor direto na sua própria conta, cobrado no preço da Hetzner sem markup; veja Add a server. Referência completa do framework em Django, e Deploy from a repository pro que mais é lido do repositório. Veja o passo a passo programático em Django on Hetzner pros detalhes de tamanho e região, e Databases and backups pra como os backups agendados do Postgres e a restauração point-in-time funcionam uma vez que ele está conectado.
Resolução de problemas
A porta 80 ou 443 já está em uso. Uma instalação padrão de servidor web geralmente pega essas primeiro. sudo ss -tlnp | grep -E ':80|:443' mostra o processo; pare ou reconfigure ele antes do Caddy iniciar.
ALLOWED_HOSTS rejeita toda requisição. O Django retorna um 400 com uma mensagem clara nomeando o host que ele esperava, nos logs que você pode ler com docker compose logs web. Adicione o domínio exato (e a variante www. se você servir os dois) em ALLOWED_HOSTS no .env, depois reinicie o serviço web.
O worker do Celery inicia mas nada nunca roda. Isso é quase sempre o CELERY_BROKER_URL apontando pra algum lugar que o worker não consegue alcançar, comumente localhost sobrando do desenvolvimento local em vez do nome do serviço redis no Compose. Confirme com docker compose exec worker env | grep CELERY.
O certificado nunca é emitido. Cheque se dig +short app.example.com resolve pro servidor primeiro; o Caddy não consegue completar um desafio HTTP-01 pra um domínio que ainda não aponta pra aqui. Também cheque se seu firewall de nuvem permite entrada nas portas 80 e 443, não só as portas que você está testando ativamente.
Falta de memória num servidor pequeno. Postgres, Redis, gunicorn e um worker do Celery somam rápido numa máquina de 4 GB se a contagem de workers do gunicorn ficar alta demais. --workers 3 aqui assume uma máquina de 4 vCPU; reduza pela metade num CX23, ou mova o Postgres pro próprio servidor quando o tráfego realmente exigir isso.
Perguntas frequentes
Eu preciso de um worker e de um processo beat? Só do beat, se você agenda tasks periódicas com @shared_task e um CELERYBEAT_SCHEDULE ou django-celery-beat. Uma aplicação que só dispara tasks sob demanda (de uma view, por exemplo) precisa só do worker.
Eu posso rodar Celery e Django no mesmo container? Você pode iniciar os dois processos num único container com um supervisor de processos, mas uma queda num deles derruba o outro também, e você perde a capacidade de escalar o worker independentemente do tráfego web. Containers separados a partir da mesma imagem, como acima, evitam os dois problemas.
Por que Caddy em vez de nginx? O Caddy solicita e renova certificados Let’s Encrypt por conta própria a partir de uma configuração de seis linhas; o nginx precisa do certbot conectado separadamente e de um cron job pra renovar. Os dois funcionam, o Caddy é só menos trabalho pra manter.
E se minha aplicação precisar de uma versão específica do Python? Mude a linha FROM python:3.12-slim pra combinar; Django e Celery suportam as versões do Python atualmente mantidas, cheque as restrições das suas próprias dependências se alguma fixar uma mais antiga.
Qualquer um dos dois caminhos termina com os mesmos três processos falando com o mesmo Postgres e Redis, o arquivo do Compose se você quiser ver cada linha, o push se você preferir voltar pro código real do Django.
Por que escolhem o Railyard para isso
- Sua própria conta de cloud. Faça deploy no DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou em qualquer servidor Linux via SSH, cobrado direto pelo seu provedor, sem markup.
- Um preço fixo por equipe. O Free é grátis para sempre: um servidor mais um servidor de banco de dados, três apps, sem cartão. O Pro custa US$19 por mês para até cinco servidores e apps ilimitados. O Team custa US$49 por mês com servidores ilimitados. Sem cobrança por app, por deploy, por assento ou por gigabyte.
- Templates de um clique. Faça deploy de apps open source, como bancos de dados, analytics e ferramentas de dev, no seu próprio servidor em um clique.
- Bancos de dados gerenciados. Postgres, MySQL e Redis com backups programados, verificados por restauração, e recuperação point-in-time.
- Push-to-deploy. Faça deploy a partir do GitHub, GitLab ou da CLI, com HTTPS automático e releases sem downtime.