← Blog

Desplegar Phoenix en Hetzner

Railyard

2 de agosto de 2026 · 7 min de lectura

Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta

mix phx.server necesita un toolchain completo de Elixir y tu árbol de código fuente en la máquina que lo corre, lo cual está bien en desarrollo y es la forma incorrecta para un servidor de producción. La respuesta propia de Phoenix es mix release: un build compilado y autocontenido con la VM de Erlang y tu código compilado empaquetados juntos, sin mix ni checkout de código fuente necesario en tiempo de ejecución. Esta guía construye esa release en un Dockerfile multi-etapa, la corre en un servidor de Hetzner Cloud con Postgres y Caddy, y termina con una app Phoenix en tu propio dominio que sabrás cómo redesplegar.

Lo que necesitas

  • Un servidor de Hetzner Cloud. Un CX23 (2 vCPU, 4 GB de memoria) a €5.99/mes sin IVA corre cómodamente una sola app Phoenix con su base de datos Postgres; sube a un CX33 (4 vCPU, 8 GB, €8.99/mes) si la app también hace procesamiento real de trabajos en segundo plano vía Oban o similar.
  • Un dominio, con el DNS ya apuntando al servidor antes de pedir HTTPS.
  • Una app Phoenix en un repositorio Git, generada con mix phx.gen.release --docker o similar, que crea el directorio rel/ que espera el Dockerfile de esta guía. Esta guía usa Phoenix 1.8.16, Elixir 1.18.5 y Erlang/OTP 27.3.4.19.
  • Docker y el plugin de Compose, ya sea localmente para probar el build primero o directamente en el servidor.

Paso 1: crea el servidor

En Hetzner Cloud Console, crea un proyecto si hace falta, luego Add Server: elige Ubuntu 24.04, el tipo de vCPU compartida CX23, una ubicación (Núremberg, Falkenstein y Helsinki quedan todas en la UE), agrega tu clave SSH, y créalo.

ssh root@your-server-ip

Paso 2: instala Docker

curl -fsSL https://get.docker.com | sh
docker --version
docker compose version

Paso 3: escribe el Dockerfile

Esto refleja el Dockerfile multi-etapa que mix phx.gen.release --docker de Phoenix genera por sí mismo, fijado a versiones específicas de Elixir, OTP y Debian para que el build sea reproducible:

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"]

Reemplaza myapp en el COPY final con el nombre real de tu aplicación OTP (el átomo en def application de mix.exs, no el nombre legible del proyecto). Constrúyelo una vez para confirmar que compila:

docker build -t my-phoenix-app .

Paso 4: escribe el archivo 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:

Tu archivo .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

Genera un SECRET_KEY_BASE real localmente antes de desplegar, ya que la release no tiene mix disponible en tiempo de ejecución para generarte uno:

mix phx.gen.secret

PHX_SERVER=true importa específicamente para una release: sin él, la release compilada arranca sin que su endpoint web escuche, lo cual normalmente está bien para una tarea mix puntual pero es incorrecto para un servidor web corriendo. Valida el archivo compose:

docker compose config

Paso 5: escribe el Caddyfile

app.example.com {
    reverse_proxy app:4000
}

Paso 6: primer arranque y migraciones

Las releases no tienen mix ecto.migrate disponible, ya que no hay mix en tiempo de ejecución; la release misma expone una tarea de migración en su lugar:

docker compose up -d
docker compose exec app /app/bin/myapp eval "MyApp.Release.migrate"
docker compose ps

Esto asume que tu directorio rel/ incluye el módulo Release que el generador de Phoenix crea exactamente para esto (lib/my_app/release.ex, con una función migrate/0 que llama a Ecto.Migrator). Deberías ver app, db y caddy todos como running, y https://app.example.com debería responder con un certificado válido.

Paso 7: despliega nuevas versiones

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 solo app deja db y caddy intactos. Las variables leídas a través de config/runtime.exs (el archivo que este Dockerfile copia justo antes de mix release, por diseño, para que se lean al arrancar en vez de quedar horneadas en la release compilada) recogen lo que haya en .env en cada reinicio, sin reconstruir.

Respaldos

docker compose exec -T db pg_dump -U app app_prod | gzip > backup-$(date +%F).sql.gz

Copia el archivo fuera del servidor con una tarea cron, y prueba una restauración periódicamente.

El atajo: hazlo con Railyard

Desplegar Phoenix en Hetzner corre el mismo proceso de build y release desde una URL de repositorio, sin que escribas el Dockerfile multi-etapa tú mismo. Conecta un servidor con un token de Hetzner Cloud Console, o trae uno ya aprovisionado. Railyard detecta ecto_sql o postgrex en mix.exs como Postgres, redix como Redis, y ecto_ch como ClickHouse, con DATABASE_URL y las demás inyectadas automáticamente. Las migraciones corren a través de la release compilada de la misma forma que el paso 6 de arriba, sin necesitar mix en tiempo de ejecución ya que Railyard construye el mismo tipo de release que esta guía hace a mano. SECRET_KEY_BASE se genera una sola vez en vez de dejarse para que lo inventes, MIX_ENV y PHX_SERVER se configuran, y mix phx.server se vuelve el comando de arranque de la release. Mira los docs de Phoenix para las reglas exactas de detección.

Solución de problemas

  • El contenedor se cierra inmediatamente sin que ningún servidor web escuche: falta PHX_SERVER=true en el entorno; una release compilada por defecto no arranca el endpoint a menos que se le indique, lo cual es el comportamiento correcto para correr una tarea eval puntual pero incorrecto para el proceso principal.
  • (RuntimeError) environment variable SECRET_KEY_BASE is missing: config/runtime.exs lo lee al arrancar y la release se cae inmediatamente sin él; confirma que de verdad está definido en .env y que .env estaba presente cuando corrió docker compose up.
  • Las migraciones nunca corren, o la app arranca contra un esquema vacío: el módulo Release y su función migrate/0 necesitan existir en el código fuente de tu app (mix phx.gen.release crea esto); sin él, el comando eval del paso 6 no tiene nada que llamar.
  • El HTTPS nunca se emite, Caddy sigue reintentando: el DNS todavía no apunta al servidor. dig +short app.example.com debería coincidir con la IP del servidor.
  • El build falla específicamente en mix assets.deploy: este paso necesita herramientas de assets basadas en Node (esbuild, tailwind) que Phoenix instala como binarios independientes en vez de vía npm; confirma que la etapa builder tenga acceso a red durante el build, ya que esos binarios se descargan al primer uso a menos que ya estén en caché en la imagen.

Preguntas frecuentes

¿Por qué fijar hexpm/elixir en vez de la imagen elixir simple? Las imágenes hexpm/elixir empaquetan una combinación específica de Elixir, Erlang/OTP y Debian en una sola etiqueta, que es lo que usa el propio generador de release de Phoenix, ya que una release es sensible a exactamente qué versión de OTP la construyó.

¿Puedo correr las migraciones automáticamente en cada arranque en vez de un comando separado? Algunos equipos llaman a la función Release.migrate/0 desde el propio script de arranque de la release en vez de correrla manualmente; es una opción razonable una vez que confías en que las migraciones siempre son seguras de correr concurrentemente con un despliegue progresivo, lo cual no es universalmente cierto para todo tipo de migración.

¿Postgres, MySQL u otra cosa? ecto_sql soporta varios adaptadores; esta guía usa Postgres por ser el emparejamiento más común con Phoenix, pero es el adaptador de Ecto el que determina qué cambia realmente, no Phoenix mismo.

¿Y si el nombre de mi app no es myapp? Reemplaza cada ocurrencia de myapp en el Dockerfile y el comando del paso 6 con el nombre real de tu app OTP, que se encuentra en mix.exs como el átomo pasado a def application, no el nombre de módulo en mayúscula.

¿De verdad alcanza un CX23 para una app Phoenix? La BEAM (la VM de Erlang) es eficiente con conexiones concurrentes incluso en hardware modesto; 4 GB es cómodo para una sola app y su base de datos Postgres, siendo el procesamiento de trabajos en segundo plano estilo Oban la razón más probable para subir de tamaño.

Una app Phoenix en un servidor de Hetzner que aprovisionaste tú mismo cuesta €5.99 al mes, construida como el mismo tipo de release que recomiendan las propias herramientas de Phoenix, en un Dockerfile que puedes leer de principio a fin. Desplegar una versión nueva una vez que existe es reconstruir un contenedor y correr un comando de migración contra la release compilada misma.

Por qué eligen Railyard para esto

  • Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
  • Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
  • Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
  • Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
  • Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.

Sáltate la configuración del servidor.

Sube un repositorio Phoenix a Railyard y construye la release, aprovisiona Postgres, y despliega en cada push.

Plan gratis, sin tarjeta, y 14 días de Pro para cada equipo nuevo. Un precio fijo por equipo.