← Blog

Fazendo deploy de Phoenix na Hetzner

Railyard

2 de agosto de 2026 · 7 min de leitura

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 --docker ou similar, que cria o diretório rel/ 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=true está 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 task eval pontual mas errado pro processo principal.
  • (RuntimeError) environment variable SECRET_KEY_BASE is missing: o config/runtime.exs lê essa variável na inicialização e a release trava imediatamente sem ela; confirme que ela realmente está definida no .env e que o .env estava presente quando o docker compose up rodou.
  • As migrations nunca rodam, ou a aplicação inicia contra um schema vazio: o módulo Release e a função migrate/0 dele precisam existir no código-fonte da sua aplicação (o mix phx.gen.release cria isso); sem ele, o comando eval do 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.com deve 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 pelo npm; 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.

Pule a configuração do servidor.

Suba um repositório Phoenix pro Railyard e ele constrói a release, provisiona o Postgres, e faz deploy em cada push.

Plano grátis, sem cartão, e 14 dias de Pro para cada equipe nova. Um preço fixo por equipe.