← Blog

Desplegar una aplicación Rails en Hetzner

Railyard

4 de abril de 2026 · 13 min de lectura

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

Los servidores en la nube de Hetzner están entre el cómputo real más barato que puedes alquilar por mes, pero un servidor barato no hace correr tu aplicación por sí solo. Igual tienes que aprovisionarlo, instalar Docker, poner Postgres corriendo junto a tu aplicación, poner HTTPS delante de ambos, guardar una copia de seguridad en algún lugar que no sea el propio servidor, y tener una forma de lanzar tu próxima versión sin empezar de cero cada vez. Al final de esta guía tendrás una aplicación Rails, construida a partir de su propio Dockerfile generado, corriendo en un servidor Hetzner detrás de Caddy con un certificado real, respaldada por Postgres, con copias de seguridad nocturnas y una forma corta y repetible de desplegar una nueva versión.

Lo que necesitas

  • Una cuenta de Hetzner Cloud y un servidor. El CX23 de costo optimizado (2 vCPU, 4 GB de memoria, 40 GB NVMe) figura a 5,99 € al mes sin IVA, el cual aplica según dónde se te facture. Revisa el precio y la disponibilidad actuales en la Hetzner Cloud Console antes de pedirlo, ya que las líneas de productos y los precios en la nube cambian.
  • Un nombre de dominio al que puedas agregarle un registro DNS.
  • Un par de llaves SSH en tu máquina (ssh-keygen -t ed25519 si todavía no tienes una).
  • Una aplicación Rails, o una nueva: esta guía construye una desde cero con rails new, así que puedes seguirla incluso sin una aplicación existente.
  • Unos quince minutos para que el servidor y el DNS se asienten, la mayoría del tiempo esperando en vez de escribiendo.

Paso 1: crear el servidor y anotar su IP

En la Hetzner Cloud Console, abre (o crea) un proyecto, y luego elige Add Server. Elige una ubicación: Nuremberg, Falkenstein o Helsinki mantienen tus datos en la Unión Europea, mientras que Ashburn, Hillsboro o Singapur también están disponibles si tus usuarios están en otra parte. Elige Ubuntu 24.04 como imagen, y luego en el tipo de servidor elige el CX23 de vCPU compartida (2 vCPU, 4 GB de memoria, 40 GB NVMe). En SSH keys, agrega la llave pública de tu máquina en lugar de depender de una contraseña generada, y luego crea el servidor.

Una vez que arranca, la consola muestra su dirección IPv4 pública. Conéctate para confirmar el acceso:

ssh root@YOUR_SERVER_IP

Deberías ver el banner de bienvenida de Ubuntu y llegar a una shell de root. Si la conexión es rechazada, revisa que la llave SSH asociada al servidor coincida con la llave privada en tu máquina.

Paso 2: apuntar tu dominio al servidor

Agrega un registro A en tu proveedor de DNS para el nombre de host que quieras usar, apuntando a la IP del servidor:

Type: A
Name: myapp (o @ para el dominio sin subdominio)
Value: YOUR_SERVER_IP
TTL: 300

La propagación del DNS puede tardar unos minutos. Revísala desde tu propia máquina antes de seguir, ya que Caddy va a necesitar que esto ya resuelva correctamente en un paso más adelante:

dig +short myapp.example.com

Deberías ver la IP de tu servidor impresa de vuelta. Si no obtienes nada, espera unos minutos y vuelve a intentarlo antes de continuar.

Paso 3: instalar Docker en el servidor

El Dockerfile que genera Rails necesita Docker Engine y el plugin de Compose en el servidor. El propio script de instalación de Docker se encarga de ambos en una máquina Ubuntu recién instalada:

apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl enable --now docker

Hetzner no pone ningún firewall delante de un servidor nuevo por defecto, así que asegura el host dejando pasar solo lo que necesitas con ufw:

ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable

Revisa la instalación:

docker --version
docker compose version

Deberías ver una versión de Docker y una versión de Compose impresas, no un error de “command not found”.

Paso 4: generar la aplicación Rails con un Dockerfile y Postgres

En tu propia máquina, genera una nueva aplicación configurada para Postgres en lugar del SQLite por defecto:

gem install rails -v 8.1.4
rails new myapp --database=postgresql
cd myapp

rails new crea un Dockerfile por defecto, listado entre los archivos generados junto con .dockerignore, y está ajustado para producción: instala tus gemas y precompila los assets en una sola etapa de build, y luego copia solo lo necesario a una imagen de ejecución más liviana que corre como un usuario que no es root. Rails 8 también incluye Thruster, un pequeño proxy HTTP delante de Puma que maneja HTTP/2, compresión y servido de assets, por lo que la imagen generada expone el puerto 80 internamente y arranca con ./bin/thrust ./bin/rails server en lugar de arrancar Puma directamente. No vas a llegar a ese puerto directamente desde Internet, Caddy se pone delante en el Paso 7.

El script de entrada del Dockerfile también ejecuta ./bin/rails db:prepare automáticamente, cada vez que el comando del contenedor es un servidor Rails, antes de que el servidor arranque de verdad. Eso significa que no necesitas un paso de migración aparte más adelante en esta guía: el primer arranque crea la base de datos, y cada arranque posterior aplica las migraciones que vinieron con esa versión.

Como pasaste --database=postgresql, config/database.yml queda configurado para la gema pg en lugar de SQLite. Si estás en Rails 8, el mismo archivo también conecta entradas de base de datos separadas para cache, queue y cable, para Solid Cache, Solid Queue y Solid Cable, los sistemas por defecto de Rails para caché y tareas en segundo plano. Las cuatro, la principal más estas tres, leen la misma DATABASE_URL y solo se diferencian en el nombre de base de datos que cada una pide (myapp_production, myapp_production_cache, y así), así que un solo contenedor de Postgres y una sola contraseña cubren todas; db:prepare crea las cuatro la primera vez que corre.

Verifica que los archivos estén ahí:

ls Dockerfile .dockerignore config/database.yml

Deberías ver las tres rutas impresas sin error.

Paso 5: llevar tus secretos de la aplicación al servidor

Rails cifra config/credentials.yml.enc con config/master.key, un archivo que está en .gitignore a propósito y que nunca se envía al servidor. Lee su valor una vez, en tu propia máquina:

cat config/master.key

En el servidor, crea un directorio para la aplicación y un archivo .env con esa llave y una contraseña de base de datos que generes tú mismo:

mkdir -p /opt/myapp
cat > /opt/myapp/.env <<'EOF'
RAILS_MASTER_KEY=paste-the-value-from-config-master-key-here
POSTGRES_PASSWORD=a-long-random-password
EOF
chmod 600 /opt/myapp/.env

Revísalo:

cat /opt/myapp/.env

Deberías ver ambas variables con valores reales, no marcadores de posición, y el archivo no debería ser legible por cualquiera (-rw------- desde ls -l).

Paso 6: escribir el archivo de Docker Compose

Copia tu aplicación al servidor (scp, rsync, o un git clone de tu repositorio dentro de /opt/myapp), y luego agrega compose.yml al lado:

services:
  app:
    build: .
    image: myapp:latest
    restart: unless-stopped
    env_file: .env
    environment:
      RAILS_ENV: production
      DATABASE_URL: postgres://myapp:${POSTGRES_PASSWORD}@db:5432/myapp_production
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - storage:/rails/storage
    expose:
      - "80"

  db:
    image: postgres:16.4-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: myapp_production
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 5s
      retries: 10

  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:
  storage:
  caddy_data:
  caddy_config:

El servicio app se conecta a db por su nombre de servicio, no por localhost, ya que Compose pone ambos contenedores en la misma red privada. depends_on: condition: service_healthy evita que la aplicación arranque antes de que Postgres pueda realmente aceptar conexiones, no solo antes de que su contenedor exista. Los volúmenes nombrados mantienen tu base de datos y los archivos de Active Storage en disco a través de reinicios y reconstrucciones; solo caddy publica puertos hacia el host, así que Postgres nunca es alcanzable desde fuera de este servidor.

Verifica que el archivo se analiza sin problemas antes de seguir:

docker compose config

Deberías ver la configuración completamente resuelta impresa sin líneas de error.

Paso 7: agregar Caddy como proxy inverso con HTTPS

Agrega un Caddyfile al lado de compose.yml:

myapp.example.com {
	reverse_proxy app:80
}

Reemplaza myapp.example.com con el dominio que apuntaste al servidor en el Paso 2. Caddy hace dos cosas por su cuenta a partir de este único bloque: reenvía cada solicitud al puerto 80 del contenedor app, y pide un certificado TLS gratuito a Let’s Encrypt para ese nombre de host exacto, renovándolo automáticamente antes de que expire. Esto solo funciona si el dominio ya resuelve hacia este servidor, por eso el DNS va antes que Caddy en esta guía.

Paso 8: levantar la pila

Desde /opt/myapp:

docker compose up -d --build

Gracias al comportamiento del script de entrada visto en el Paso 4, este único comando construye la imagen, arranca Postgres, espera a que se reporte saludable, y luego arranca la aplicación, que crea la base de datos y aplica las migraciones por su cuenta antes de que Puma (detrás de Thruster) arranque de verdad.

Verifica la pila:

docker compose ps
curl -I https://myapp.example.com

Deberías ver los tres servicios en estado Up (y db en estado healthy), y el curl debería devolver HTTP/2 200 sin advertencia de certificado.

Paso 9: respaldar Postgres

Un dump tomado fuera del volumen de datos es lo que realmente te protege si el servidor desaparece. Agrega un script de respaldo:

cat > /opt/myapp/backup.sh <<'EOF'
#!/bin/sh
cd /opt/myapp
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U myapp myapp_production | gzip > /opt/myapp/backups/$TIMESTAMP.sql.gz
find /opt/myapp/backups -name '*.sql.gz' -mtime +14 -delete
EOF
mkdir -p /opt/myapp/backups
chmod +x /opt/myapp/backup.sh

Prográmalo con cron, todos los días a las 3 a. m.:

(crontab -l 2>/dev/null; echo "0 3 * * * /opt/myapp/backup.sh") | crontab -

Ejecútalo una vez a mano y verifica que haya funcionado:

/opt/myapp/backup.sh
ls -lh /opt/myapp/backups

Deberías ver un archivo .sql.gz con la marca de tiempo de hoy y un tamaño distinto de cero. Para cualquier cosa más allá de un proyecto personal, copia también estos archivos fuera del servidor, a otra máquina o a un almacenamiento de objetos, para que perder el servidor no signifique también perder el respaldo.

Paso 10: lanzar una actualización

Cada cambio posterior sigue la misma forma: llevar el código nuevo al servidor, reconstruir la imagen, reemplazar el contenedor en ejecución.

git pull
docker compose build app
docker compose up -d app

El script de entrada vuelve a correr db:prepare en este nuevo contenedor antes de que el servidor arranque, así que cualquier migración que haya venido con esta versión se aplica automáticamente. docker compose up -d app solo recrea el contenedor app, no db ni caddy, así que Postgres sigue corriendo y el estado del certificado de Caddy no se toca. Hay un pequeño lapso, uno o dos segundos, mientras el contenedor anterior se detiene y el nuevo arranca, ya que esta configuración manual corre un solo contenedor de aplicación. Verifica que haya quedado bien:

docker compose logs --tail=50 app
curl -I https://myapp.example.com

Deberías ver tu aplicación arrancar limpiamente en los logs y el curl seguir devolviendo 200 en la nueva versión.

El atajo: hazlo con Railyard

Todo lo anterior, el servidor, Docker, Postgres, Caddy, los respaldos, un lanzamiento repetible, es también lo que Railyard automatiza para una aplicación Rails en Hetzner, sin que tengas que escribir ninguno de los archivos de arriba a mano.

Pega la URL de tu repositorio en https://app.railyard.run/deploy?repo= y elige Hetzner como proveedor. La primera vez vas a generar un token en la Hetzner Cloud Console (abre tu proyecto, luego Security, luego API tokens, y genera uno con permiso Read & Write) para que Railyard pueda aprovisionar el servidor en tu nombre. Para una aplicación Rails en Hetzner, el valor por defecto de Railyard es un servidor de 2 vCPU y 4 GB, el mismo tamaño usado manualmente arriba, y puedes elegir cualquier región de Hetzner, incluyendo Nuremberg, Falkenstein o Helsinki si la residencia de datos en la Unión Europea te importa.

Desde ahí, Railyard lee tu aplicación de la misma forma en que esta guía lo hizo a mano: ve pg en tu Gemfile.lock y aprovisiona una base de datos Postgres gestionada en lugar de SQLite, y ejecuta bin/rails db:prepare antes de cada lanzamiento de la misma forma en que lo hizo manualmente el script de entrada del Dockerfile en el Paso 8. Si estás en Rails 8, les da a las bases de datos cache, queue y cable su propia cadena de conexión en lugar de dejarte razonarlo como en el Paso 4. Active Storage en disco obtiene un volumen persistente automáticamente, igual que el volumen storage del Paso 6, y SECRET_KEY_BASE se genera una sola vez y se conserva, así que nunca tienes que gestionarlo a mano. Si tu Gemfile.lock incluye Sidekiq, Solid Queue, GoodJob, Resque o Delayed Job, Railyard lo ofrece como proceso worker junto a tu proceso web, sin un servicio de compose aparte que escribir. El HTTPS y el proxy inverso del Paso 7 se manejan de la misma forma, apuntando a tu dominio una vez que lo agregas.

Lee más en desplegar desde un repositorio, bases de datos y copias de seguridad, el botón de despliegue en un clic, primeros pasos, la guía de referencia de Rails en Hetzner, y las comparaciones de costos de hospedaje si quieres ver cómo se compara el costo del servidor solo aquí con una ruta gestionada. El plan gratuito de Railyard cubre un servidor más un servidor de base de datos y tres aplicaciones sin necesidad de tarjeta, Pro cuesta $19 al mes por equipo, y Team cuesta $49 al mes por equipo.

Solución de problemas

El puerto ya está en uso. Si docker compose up falla porque algo ya está enlazado al puerto 80 o 443, puede que Apache o Nginx por defecto de Ubuntu ya esté instalado y corriendo. Revisa con ss -tlnp | grep -E ':80|:443' y detén o quita el servicio que lo esté usando antes de que Caddy intente enlazarse ahí.

RAILS_MASTER_KEY o credentials incorrectos. Un error como ActiveSupport::MessageEncryptor::InvalidMessage al arrancar casi siempre significa que el RAILS_MASTER_KEY en /opt/myapp/.env no coincide con la llave que cifró config/credentials.yml.enc. Vuelve a ejecutar cat config/master.key localmente y compáralo carácter por carácter con lo que está en .env en el servidor.

Conexión a Postgres rechazada. Si los logs de la aplicación muestran could not connect to server: Connection refused, revisa que DATABASE_URL use db como nombre de host, no localhost ni 127.0.0.1. Dentro de la red de Compose, db es la única dirección que resuelve hacia el contenedor de Postgres; localhost se refiere al propio contenedor app, que no tiene ninguna base de datos corriendo adentro.

El HTTPS no se emite. Si docker compose logs caddy muestra errores de certificado repetidos o NXDOMAIN, tu registro DNS del Paso 2 todavía no se ha propagado o apunta a la IP equivocada. Vuelve a ejecutar dig +short myapp.example.com, confirma que coincide con el servidor, y luego reinicia Caddy con docker compose restart caddy cuando sea así.

Falta de memoria durante la precompilación de assets. Los 4 GB de memoria de un CX23 suelen ser suficientes, pero docker compose build igual puede ser terminado a la mitad de la compilación de assets en una aplicación más grande, sobre todo si está construyendo y corriendo al mismo tiempo. Agrega un archivo swap de 2 GB antes de construir: fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile, y luego hazlo permanente agregándolo a /etc/fstab.

Preguntas frecuentes

¿Un CX23 alcanza para producción? Para una aplicación Rails de tamaño pequeño a mediano con tráfico moderado, sí, ya que Puma, Postgres y Caddy juntos caben cómodamente en 4 GB, y el arreglo de swap de arriba cubre el único caso, builds de assets grandes, donde la memoria se pone ajustada.

¿Puedo saltarme Postgres y quedarme con SQLite? Puedes; SQLite funciona bien en un solo servidor mientras montes su archivo de base de datos en un volumen persistente para que sobreviva a los reinicios del contenedor, pero Postgres da un camino más limpio si en algún momento agregas un segundo servidor de aplicación.

¿Cómo agrego Redis o Sidekiq? Agrega un servicio redis a compose.yml usando la imagen oficial redis con su propio volumen nombrado, agrega REDIS_URL al entorno de app apuntando a redis://redis:6379/0, y agrega un servicio worker que ejecute bundle exec sidekiq contra la misma imagen en lugar del comando del servidor Rails.

¿Esto sobrevive a un reinicio del servidor? Sí, restart: unless-stopped en cada servicio hace que Docker levante toda la pila automáticamente después de que el servidor se reinicia, siempre que el propio servicio systemd de Docker esté habilitado, lo cual hace por defecto el script de instalación del Paso 3.

¿Qué son Solid Cache, Solid Queue y Solid Cable? Son los sistemas por defecto de Rails 8 para caché, tareas en segundo plano y Action Cable, todos respaldados por una base de datos en lugar de un servicio aparte como Redis. Por eso una aplicación Rails 8 recién creada necesita cuatro nombres de base de datos en lugar de uno, y por eso el único contenedor de Postgres y la única contraseña de esta guía cubren todos.

Eso es una configuración de producción completa: un servidor, Docker Compose corriendo tu aplicación y Postgres, Caddy manejando el HTTPS, un respaldo nocturno, y un lanzamiento de dos comandos. Guarda este compose.yml y este Caddyfile en tu repositorio junto a la aplicación, y cada futuro despliegue es el mismo git pull, build, reinicio que acabas de hacer en el Paso 10.

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.

Salta la configuración del servidor

Pega la URL de tu repositorio en Railyard y obtén Postgres, HTTPS y copias de seguridad configurados automáticamente en Hetzner.

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