Fazendo deploy de Phoenix na Hetzner
Siga isso no seu próprio servidor. Comece grátis, sem cartão
mix phx.server precisa de um toolchain completo de Elixir e da sua árvore de código na máquina que o roda, o que é ótimo em desenvolvimento e a forma errada pra um servidor de produção. A própria resposta do Phoenix é o mix release: um build compilado e autocontido com a VM do Erlang e seu código compilado empacotados juntos, sem precisar de mix ou de um checkout do código-fonte em tempo de execução. Este guia constrói essa release num Dockerfile de múltiplos estágios, roda ela num servidor Hetzner Cloud com Postgres e Caddy, e termina com uma aplicação Phoenix no seu próprio domínio que você sabe como atualizar.
O que você vai precisar
- Um servidor Hetzner Cloud. Um CX23 (2 vCPU, 4 GB de memória) a €5,99/mês sem IVA roda uma única aplicação Phoenix com o banco Postgres dela com conforto; suba pra um CX33 (4 vCPU, 8 GB, €8,99/mês) se a aplicação também roda processamento real de jobs em segundo plano pelo Oban ou similar.
- Um domínio, com o DNS já apontado pro servidor antes de solicitar o HTTPS.
- Uma aplicação Phoenix num repositório Git, gerada com
mix phx.gen.release --dockerou similar, que cria o diretóriorel/que o Dockerfile deste guia espera. Este guia usa Phoenix 1.8.16, Elixir 1.18.5 e Erlang/OTP 27.3.4.19. - Docker e o plugin do Compose, localmente pra testar o build primeiro ou direto no servidor.
Passo 1: criar o servidor
No Hetzner Cloud Console, crie um projeto se precisar, depois Add Server: escolha Ubuntu 24.04, o tipo de vCPU compartilhada CX23, uma localização (Nuremberg, Falkenstein e Helsinki todas ficam na UE), adicione sua chave SSH, e crie ele.
ssh root@your-server-ip
Passo 2: instalar o Docker
curl -fsSL https://get.docker.com | sh
docker --version
docker compose version
Passo 3: escrever o Dockerfile
Isso espelha o Dockerfile de múltiplos estágios que o próprio mix phx.gen.release --docker do Phoenix gera, fixado em versões específicas de Elixir, OTP e Debian pra que o build seja reproduzível:
ARG ELIXIR_VERSION=1.18.5
ARG OTP_VERSION=27.3.4.19
ARG DEBIAN_VERSION=bookworm-20261020
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}-slim"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}-slim"
FROM ${BUILDER_IMAGE} AS builder
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
ENV MIX_ENV="prod"
WORKDIR /app
RUN mix local.hex --force && mix local.rebar --force
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
COPY priv priv
COPY lib lib
COPY assets assets
RUN mix assets.deploy
RUN mix compile
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
FROM ${RUNNER_IMAGE}
RUN apt-get update -y \
&& apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
ENV LANG=C.UTF-8
WORKDIR /app
RUN chown nobody /app
ENV MIX_ENV="prod"
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/myapp ./
USER nobody
CMD ["/app/bin/server"]
Troque myapp no COPY final pelo nome real da sua aplicação OTP (o atom em def application no mix.exs, não o nome legível do projeto). Construa uma vez pra confirmar que compila:
docker build -t my-phoenix-app .
Passo 4: escrever o arquivo do Compose
services:
app:
build: .
restart: unless-stopped
env_file:
- .env
depends_on:
db:
condition: service_healthy
expose:
- "4000"
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
depends_on:
- app
volumes:
db-data:
caddy-data:
caddy-config:
Seu arquivo .env:
POSTGRES_DB=app_prod
POSTGRES_USER=app
POSTGRES_PASSWORD=change-this
DATABASE_URL=ecto://app:change-this@db/app_prod
SECRET_KEY_BASE=generate-this-with-mix-phx-gen-secret
PHX_HOST=app.example.com
PORT=4000
MIX_ENV=prod
PHX_SERVER=true
Gere um SECRET_KEY_BASE real localmente antes de fazer o deploy, já que a release não tem mix disponível em tempo de execução pra gerar um pra você:
mix phx.gen.secret
PHX_SERVER=true importa especificamente pra uma release: sem isso, a release compilada inicia sem o endpoint web escutando, o que normalmente é bom pra uma task pontual do mix, mas errado pra um servidor web em execução. Valide o arquivo do compose:
docker compose config
Passo 5: escrever o Caddyfile
app.example.com {
reverse_proxy app:4000
}
Passo 6: primeiro boot e migrations
Releases não têm mix ecto.migrate disponível, já que não há mix em tempo de execução; a própria release expõe uma task de migration no lugar:
docker compose up -d
docker compose exec app /app/bin/myapp eval "MyApp.Release.migrate"
docker compose ps
Isso assume que seu diretório rel/ inclui o módulo Release que o gerador do Phoenix cria exatamente pra esse propósito (lib/my_app/release.ex, com uma função migrate/0 chamando Ecto.Migrator). Você deve ver app, db e caddy todos running, e https://app.example.com deve responder com um certificado válido.
Passo 7: fazer deploy de novas versões
git pull
docker compose build app
docker compose up -d --no-deps app
docker compose exec app /app/bin/myapp eval "MyApp.Release.migrate"
Reconstruir só o app deixa db e caddy intocados. Variáveis lidas através do config/runtime.exs (o arquivo que esse Dockerfile copia pra dentro bem antes do mix release, de propósito, pra que sejam lidas na inicialização em vez de embutidas na release compilada) pegam o que estiver no .env em cada restart, sem precisar de rebuild.
Backups
docker compose exec -T db pg_dump -U app app_prod | gzip > backup-$(date +%F).sql.gz
Copie o arquivo pra fora do servidor num cron job, e teste uma restauração periodicamente.
O caminho mais curto: fazer isso com o Railyard
Deploy Phoenix to Hetzner roda o mesmo processo de build e release a partir de uma URL de repositório, sem você escrever o Dockerfile de múltiplos estágios você mesmo. Conecte um servidor com um token do Hetzner Cloud Console, ou traga um já provisionado. O Railyard detecta ecto_sql ou postgrex no mix.exs como Postgres, redix como Redis, e ecto_ch como ClickHouse, com DATABASE_URL e os outros injetados automaticamente. As migrations rodam através da release compilada do mesmo jeito que o Passo 6 acima, sem precisar de mix em tempo de execução já que o Railyard constrói o mesmo tipo de release que este guia faz à mão. SECRET_KEY_BASE é gerado uma vez em vez de deixado pra você inventar, MIX_ENV e PHX_SERVER são definidos, e mix phx.server se torna o comando de start da release. Veja os docs do Phoenix pras regras exatas de detecção.
Resolução de problemas
- O container encerra imediatamente sem nenhum servidor web escutando:
PHX_SERVER=trueestá faltando no ambiente; uma release compilada por padrão não inicia o endpoint a menos que instruída a fazer isso, o que é um comportamento correto pra rodar uma taskevalpontual mas errado pro processo principal. (RuntimeError) environment variable SECRET_KEY_BASE is missing: oconfig/runtime.exslê essa variável na inicialização e a release trava imediatamente sem ela; confirme que ela realmente está definida no.enve que o.envestava presente quando odocker compose uprodou.- As migrations nunca rodam, ou a aplicação inicia contra um schema vazio: o módulo
Releasee a funçãomigrate/0dele precisam existir no código-fonte da sua aplicação (omix phx.gen.releasecria isso); sem ele, o comandoevaldo Passo 6 não tem nada pra chamar. - O HTTPS nunca é emitido, o Caddy continua tentando de novo: o DNS ainda não está apontando pro servidor.
dig +short app.example.comdeve bater com o IP do servidor. - O build falha especificamente no
mix assets.deploy: essa etapa precisa de ferramentas de assets baseadas em Node (esbuild, tailwind) que o Phoenix instala como binários standalone em vez de pelonpm; confirme que o estágio builder tem acesso à rede durante o build, já que esses binários são baixados no primeiro uso a menos que já estejam em cache na imagem.
Perguntas frequentes
Por que fixar a hexpm/elixir em vez da imagem elixir simples? As imagens hexpm/elixir empacotam uma combinação específica de Elixir, Erlang/OTP e Debian numa única tag, que é o que o próprio gerador de release do Phoenix usa, já que uma release é sensível a exatamente qual versão do OTP a construiu.
Eu posso rodar migrations automaticamente em todo boot em vez de um comando separado? Algumas equipes chamam a função Release.migrate/0 a partir do próprio script de start da release em vez de rodar ela manualmente; essa é uma escolha razoável uma vez que você confia que as migrations são sempre seguras pra rodar concorrentemente com um deploy gradual, o que não é universalmente verdade pra todo tipo de migration.
Postgres, MySQL ou outra coisa? O ecto_sql suporta vários adapters; este guia usa Postgres já que é a combinação mais comum com o Phoenix, mas o adapter do Ecto determina o que realmente muda, não o Phoenix em si.
E se o nome da minha aplicação não for myapp? Troque toda ocorrência de myapp no Dockerfile e no comando do Passo 6 pelo nome real da sua aplicação OTP, encontrado no mix.exs como o atom passado pro def application, não o nome do módulo com letra maiúscula.
Um CX23 é realmente suficiente pra uma aplicação Phoenix? A BEAM (a VM do Erlang) é eficiente com conexões concorrentes mesmo em hardware modesto; 4 GB é confortável pra uma única aplicação e o banco Postgres dela, com processamento de jobs em segundo plano no estilo Oban sendo o motivo mais provável pra subir de tamanho.
Uma aplicação Phoenix num servidor Hetzner que você mesmo provisionou custa €5,99 por mês, construída como o mesmo tipo de release que a própria ferramenta do Phoenix recomenda, num Dockerfile que você consegue ler de ponta a ponta. Lançar uma versão nova depois que ela existe é um rebuild de um container e um comando de migration rodado contra a própria release compilada.
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.