Mova uma aplicação Django do Heroku pra DigitalOcean
Siga isso no seu próprio servidor. Comece grátis, sem cartão
Os dynos, as config vars e o heroku pg:backups do Heroku escondem uma quantidade real de trabalho de infraestrutura atrás de um CLI, que é exatamente por que sair é mais trabalhoso do que dar push pra outro lugar. Uma aplicação Django no Heroku precisa de um servidor que fique de pé, um gerenciador de processo rodando o gunicorn, um worker Celery se a aplicação tiver um, um banco de dados Postgres com os dados reais dentro, toda config var mapeada pra um ambiente real, e um domínio com cutover feito sem uma lacuna onde nenhum dos dois servidores tem dados atuais. Este guia faz tudo isso contra um droplet da DigitalOcean, no Docker, terminando com a mesma aplicação rodando numa infraestrutura que você controla.
O que você vai precisar
- Um droplet da DigitalOcean. O plano Basic com 1 vCPU / 2 GB de RAM roda Postgres, gunicorn e Caddy juntos confortavelmente e custa atualmente US$12/mês; o plano de 1 GB (US$6/mês) é apertado demais quando o Postgres também está rodando junto com tudo mais.
- O Heroku CLI, já autenticado, pra baixar config vars e um dump de banco de dados.
- Um domínio apontado atualmente pro Heroku, com acesso pra mudar o DNS dele.
- O código fonte da aplicação num repositório Git, com um
requirements.txtque o Heroku já usa pro build. Este guia usa Django 6.1.2, gunicorn 26.2.0 como servidor WSGI, e psycopg2-binary 2.9.13 pra falar com o Postgres. - Docker e o plugin Compose, tanto localmente pra testar o build primeiro quanto no servidor.
Passo 1: faça um inventário do que realmente está rodando no Heroku
A API do Heroku não expõe o seu repositório diretamente, então essa etapa é manual, e pular ela é o jeito mais fácil de perder algo no servidor novo. Baixe as três coisas que você precisa:
heroku config -a your-app-name > heroku-config.txt
heroku ps -a your-app-name
heroku addons -a your-app-name
heroku ps mostra a sua formation atual. Pra uma aplicação Django isso geralmente é um dyno web rodando gunicorn, e talvez um dyno worker rodando Celery se a aplicação processa jobs em segundo plano. heroku addons mostra o que de fato está provisionado, quase sempre heroku-postgresql em algum plano, que precisa de um container Postgres equivalente no servidor novo.
Passo 2: crie o droplet
No painel da DigitalOcean, crie um droplet novo: escolha a imagem Ubuntu 24.04 LTS, o plano Basic de 2 GB, uma região próxima dos seus usuários, e a sua chave SSH. Depois que ele estiver rodando, anote o endereço IP público dele e aponte o registro A do seu domínio pra ele:
myproject.example.com. A 203.0.113.10
A propagação de DNS pode levar de alguns minutos a algumas horas; você pode continuar configurando o servidor enquanto isso. Entre via SSH como root e instale o Docker:
ssh root@203.0.113.10
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Cheque se funcionou:
docker compose version
Passo 3: prepare a aplicação Django pra produção
O Procfile e os dynos efêmeros do Heroku escondem algumas configurações que o Django precisa ter definidas explicitamente num servidor que você gerencia sozinho. No settings.py:
import os
DEBUG = False
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": "db",
"PORT": "5432",
}
}
STATIC_URL = "/static/"
STATIC_ROOT = "/app/staticfiles"
Se a aplicação atualmente lê DATABASE_URL como uma string única através do django-environ ou dj-database-url no Heroku, isso continua funcionando; as variáveis de ambiente acima são só a versão mais simples depois que o banco de dados é um container chamado db em vez do próprio host Postgres do Heroku. Confirme que as versões exatas fixadas no requirements.txt correspondem ao que este guia assume:
django==6.1.2
gunicorn==26.2.0
psycopg2-binary==2.9.13
Passo 4: escreva o Dockerfile
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
CMD ["sh", "-c", "python manage.py migrate --no-input && gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 3"]
Substitua myproject.wsgi pelo pacote real do seu projeto. Rodar migrate antes do gunicorn iniciar significa que todo deploy aplica as migrations pendentes automaticamente, que substitui o que quer que fosse a etapa de release que o Heroku estava rodando (uma linha release: python manage.py migrate no Procfile, provavelmente). Três workers do gunicorn é um ponto de partida razoável pra um droplet de 2 GB; acompanhe a memória sob carga e ajuste.
Passo 5: escreva o docker-compose.yml
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myproject
POSTGRES_USER: myproject
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myproject"]
interval: 5s
timeout: 5s
retries: 5
web:
build: .
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
volumes:
- static_files:/app/staticfiles
expose:
- "8000"
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- static_files:/srv/static
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
volumes:
postgres_data:
static_files:
caddy_data:
caddy_config:
Adicione um serviço worker construído a partir da mesma imagem, rodando celery -A myproject worker --loglevel=info, se a saída do heroku ps do Passo 1 mostrou um dyno worker. Ele precisa do mesmo arquivo .env e de um broker (Redis ou RabbitMQ) como outro container, do mesmo jeito que o Postgres é um aqui. Só o Caddy publica portas pro mundo exterior; web e db ficam acessíveis só a partir de containers na mesma rede do Compose.
Passo 6: mapeie as config vars do Heroku pro .env
Abra o heroku-config.txt do Passo 1 e traduza ele, variável por variável, pro .env do servidor, mantido fora do controle de versão:
POSTGRES_DB=myproject
POSTGRES_USER=myproject
POSTGRES_PASSWORD=change-this
DJANGO_ALLOWED_HOSTS=myproject.example.com
SECRET_KEY=paste-from-heroku-config-vars
SECRET_KEY é a única variável aqui que precisa de cuidado especial. O SECRET_KEY do Django assina sessões e outros cookies assinados; não é uma chave de criptografia pra dados armazenados do jeito que o RAILS_MASTER_KEY do Rails é. Essa distinção importa pro que acontece se você errar: regenerar o SECRET_KEY em vez de copiar o valor que o Heroku já tinha nas config vars dele não torna nada no banco de dados ilegível, mas a sessão de todo usuário logado e todo cookie assinado que a aplicação emitiu fica silenciosamente invalidado no momento em que o valor novo entra em vigor, deslogando todo mundo de uma vez. Copie ele exatamente a partir do heroku-config.txt em vez de gerar um novo, a não ser que forçar todo usuário a logar de novo seja realmente um custo aceitável pra esse cutover. Toda outra variável específica da aplicação do heroku-config.txt (chaves de API, feature flags, qualquer coisa que os.environ.get lê no seu próprio código) também pertence a esse arquivo; DATABASE_URL, se a aplicação usa ela como uma string única, recebe um valor novo apontando pro container db em vez do próprio host Postgres do Heroku.
Passo 7: migre o banco de dados
Com a aplicação ainda não rodando no servidor novo, baixe um dump novo do Heroku:
heroku pg:backups:capture -a your-app-name
heroku pg:backups:download -a your-app-name -o latest.dump
Copie ele pro servidor novo e restaure dentro do container Postgres recém-criado (vazio):
scp latest.dump root@203.0.113.10:/opt/app/
docker compose up -d db
docker compose exec -T db pg_restore -U myproject -d myproject --no-owner --role=myproject /tmp/latest.dump
--no-owner importa especificamente aqui: o dump do Heroku pertence a um nome de role que não existe no seu container Postgres novo, e sem --no-owner, a restauração falha no meio do caminho em todo objeto que ela não consegue reatribuir.
Passo 8: suba a stack completa e verifique
docker compose up -d --build
docker compose ps
Você deve ver web (e worker, se presente), db e caddy todos running. Antes de fazer o cutover de DNS, teste contra o IP puro do droplet com um header Host pra confirmar que a aplicação de fato serve páginas reais com dados reais:
curl -H "Host: myproject.example.com" http://203.0.113.10
Um 400 Bad Request aqui quase sempre significa que DJANGO_ALLOWED_HOSTS no .env não corresponde ao domínio no header Host; corrija e refaça o deploy do web antes de continuar.
Passo 9: escreva o Caddyfile e faça o cutover de DNS
myproject.example.com {
encode gzip
handle_path /static/* {
root * /srv/static
file_server
}
reverse_proxy web:8000
}
Arquivos estáticos sob /static/ são servidos direto pelo Caddy a partir do volume compartilhado; todo o resto passa por proxy pro gunicorn. Reduza o TTL de DNS do seu domínio um dia ou dois antes do cutover se ele estiver alto atualmente, depois aponte o registro A pro IP do droplet novo. O Heroku continua servindo os dados antigos até o DNS se propagar, que também é a janela de risco real: qualquer escrita que aconteça no Heroku depois do dump do Passo 7 mas antes do tráfego se mover totalmente pro servidor novo é uma linha que essa migração não tem. Uma janela de manutenção curta, ou um dump incremental final tirado bem antes da troca de DNS, fecha essa lacuna pra qualquer coisa que não possa tolerar perder alguns minutos de escritas.
O caminho mais rápido: faça isso com a Railyard
Colar o nome de uma aplicação do Heroku (ou conectar o Heroku direto) em import from another platform lê as mesmas três coisas que o Passo 1 baixou manualmente: config vars, formation (tipos de processo web e worker com as contagens deles), e add-ons, mapeando heroku-postgresql pra um banco de dados Postgres provisionado automaticamente. O Heroku não expõe o seu repositório através da API dele de qualquer jeito, então você ainda cola a URL do Git da qual você faz push, a única etapa manual honesta que a Railyard também não consegue pular. A migração de banco de dados dos Passos 7 e 8 se torna uma importação só: aponte pro banco de dados em produção ou envie um dump, e ela faz backup do que está ali primeiro, restaura, depois roda as migrations automaticamente, o mesmo comportamento de pg_restore --no-owner por baixo sem você precisar lembrar da flag manual. Veja database imports pro fluxo exato, e deploy Django to DigitalOcean pra como a Railyard detecta uma aplicação Django a partir do requirements.txt e configura gunicorn, migrations e Postgres sem o Dockerfile e o arquivo Compose que este guia escreve manualmente.
O plano Free da Railyard cobre um servidor mais um servidor de banco de dados e três aplicações sem precisar de cartão; o Pro é US$19/mês por equipe, e o Team é US$49/mês por equipe pra projetos maiores. Veja migrating from Heroku pro quadro completo do que se move e do que não se move.
Resolução de problemas
pg_restore: error: could not execute query ... role "..." does not exist:--no-ownerficou de fora do comando de restauração no Passo 7; adicione e os mesmos objetos restauram com sucesso sob o role conectando em vez disso.- “Bad Request (400)” do Django:
DJANGO_ALLOWED_HOSTSnão corresponde ao domínio que você está visitando. Confirme no.env, refaça o deploy comdocker compose up -d web, e chequedocker compose logs web. - Todo usuário é deslogado depois do cutover:
SECRET_KEYno.envnão corresponde ao valor que as config vars do Heroku tinham, geralmente porque foi gerado do zero no Passo 6 em vez de copiado; toda sessão e cookie assinado já foi invalidado pela incompatibilidade, e copiar o valor original depois não corrige retroativamente sessões criadas depois que a chave errada entrou em vigor. - Jobs em segundo plano nunca processam: confirme que o serviço
workerde fato está presente nodocker compose pse que a URL do broker dele corresponde à do serviçoweb; um job colocado numa fila contra um broker e um worker consultando outro nunca se encontram. - O HTTPS nunca é emitido depois do cutover de DNS: a propagação de DNS pode levar mais tempo do que esperado dependendo do TTL anterior;
dig +short myproject.example.comdeve corresponder ao IP do droplet novo consistentemente a partir de vários lugares antes de assumir que o problema é o Caddy. - Porta 80 ou 443 já em uso: outro servidor web, geralmente um nginx instalado por uma imagem de um clique da DigitalOcean, já está vinculado a ela. Pare e desabilite:
systemctl stop nginx && systemctl disable nginx. - Os dados parecem obsoletos depois do cutover: isso é a lacuna de escrita do Passo 9; se isso importar pra essa aplicação, refaça o dump do Passo 7 e a restauração do Passo 8 imediatamente antes de trocar o DNS, com as escritas pausadas no Heroku nesse meio tempo.
Perguntas frequentes
Eu consigo fazer isso com zero downtime? Não com um único dump-e-restauração do jeito que este guia faz; uma configuração de replicação lógica do Heroku Postgres pro droplet novo elimina a lacuna de escrita totalmente, mas é significativamente mais configuração do que a maioria das aplicações pequenas precisa pelos poucos minutos de risco que uma janela de manutenção cobre.
E o sistema de arquivos efêmero do Heroku, alguma coisa depende dele? Se a aplicação escreve uploads de usuário direto no disco local esperando que persistam, isso já estava quebrado no Heroku entre restarts de dyno; essa migração é um bom momento pra mover o armazenamento de mídia pra um object store ou um volume Docker se isso ainda não tinha sido feito.
Eu preciso migrar jobs agendados também? As entradas do Heroku Scheduler não são cobertas por heroku config ou heroku ps; cheque o painel do add-on Heroku Scheduler separadamente e recrie elas como entradas de cron ou um agendamento de cron dedicado da Railyard, já que nada nos Passos 1 a 9 acima mostra elas automaticamente.
Um droplet de 2 GB é mesmo comparável a um dyno do Heroku? A CPU e a memória reais de um dyno standard-1x ou standard-2x são modestas em comparação; um droplet de 2 GB geralmente roda Postgres, gunicorn e Caddy juntos por menos do que o custo equivalente do dyno mais o add-on heroku-postgresql, embora o teste real seja a pegada de memória real da sua própria aplicação sob tráfego real, não uma folha de especificações.
E se a migração der errado depois que o DNS já trocou? Mantenha a aplicação do Heroku rodando (não reduzida a zero) por pelo menos alguns dias depois do cutover especificamente pra o DNS poder ser apontado de volta pra ela imediatamente se algo estiver errado no droplet novo; esse é o plano de rollback real, não um arquivo de backup.
Uma aplicação Django que estava custando um dyno do Heroku e um add-on heroku-postgresql todo mês agora roda num droplet da DigitalOcean a US$12, com os mesmos dados de Postgres, as mesmas sessões assinadas, e os mesmos jobs em segundo plano, só rodando em algum lugar que você provisionou e entende completamente.
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.