Nixpacks o Dockerfile: empaquetar apps sin escribir un Dockerfile
Escribir un Dockerfile no es difícil. Lo difícil es mantenerlo: la imagen base que envejece, el apt-get update que un día se rompe porque el repositorio cambió, el USER que alguien quita sin querer y el caché que se invalida en cada commit. Para una aplicación pequeña, esa mantenimiento no compensa. Para eso existen los constructores de imágenes sin configuración: herramientas que leen tu repositorio, detectan el lenguaje, resuelven las versiones y producen una imagen OCI lista para desplegar.
Los dos nombres que dominarás en ese mundo son Nixpacks y Railpack. Y hay una novedad importante que cambia la recomendación: Nixpacks está en modo mantenimiento. El propio repositorio lo advierte en el README: el proyecto no está bajo desarrollo activo y recomiendan usar Railpack como reemplazo. El último release, el 1.41.0 del 24 de octubre de 2025, fue prácticamente el anuncio de esa transición: añadió los avisos de mantenimiento, soporte de Node 24 y un refactor del mapeo de versiones a archivo. Railpack está escrito en Go, usa BuildKit de verdad y es el constructor que Railway documenta hoy en su guía de builds.
En este artículo vamos a poner las dos herramientas lado a lado sobre la misma aplicación, con los comandos reales ejecutados, y a terminar con una recomendación concreta: para un proyecto nuevo, el orden es Railpack primero y Dockerfile como plan B, no al revés. También vamos a medir los tiempos y los tamaños de imagen en la misma máquina, porque las cifras de los blogs suelen comparar cosas distintas.
TL;DR — Lo que vas a tener al final
- Recomendación corta: empieza con Railpack. Nixpacks funciona, pero está congelado en modo mantenimiento y su último release es de octubre de 2025.
- Qué gana Railpack en la práctica: imágenes finales más pequeñas y builds más rápidos. En nuestras pruebas, la misma app Node produjo 449 MB con Railpack frente a 624 MB con Nixpacks, y el build tardó 18 segundos contra 71 segundos.
- Requisito real de Railpack: necesita un demonio BuildKit. Si no tienes
BUILDKIT_HOSTconfigurado, el build falla con un mensaje que te dice exactamente cómo levantarlo. - Cuándo el Dockerfile gana: cuando necesitas control fino de la imagen final: usuario sin privilegios, apk/apt exactos, multi-stage estricto, o un proceso de build no estándar.
- Comandos que vas a necesitar:
nixpacks detect,nixpacks plan,nixpacks buildy, del lado nuevo,railpack info,railpack plan,railpack build.
Qué es Nixpacks y por qué todavía importa
Nixpacks es un proyecto de Railway escrito en Rust bajo licencia MIT. La idea es simple: toma un directorio con tu código fuente, más los paquetes Nix que necesite, y produce una imagen compatible con OCI. El README lo resume como la fórmula fuente de la aplicación + paquetes Nix + Docker = imagen. La herramienta existe desde hace casi cuatro años y llegó a construir más de 14 millones de aplicaciones en la plataforma Railway, según el anuncio oficial de Railpack de marzo de 2025.
Su valor estuvo en la detección automática: si hay un package.json es Node, si hay requirements.txt o pyproject.toml es Python, y así con más de veinte proveedores. A partir de ahí genera un plan de build con fases y lo convierte en un Dockerfile que BuildKit ejecuta. El resultado es que un servicio que antes necesitaba un Dockerfile de cuarenta líneas se despliega con un git push.
El problema es que el modelo de versiones de Nix no da lo que un constructor de imágenes necesita. Railway lo explicó en su blog con bastante claridad: el versionado de Nix está atado a commits concretos del repositorio nixpkgs, así que en la práctica solo quedaba disponible la última versión mayor de cada lenguaje. Para Node y Python eso significaba exactamente una versión mayor disponible, y si un build se rompía al cambiar el commit de nixpkgs, el proyecto se caía sin que nadie hubiera tocado nada. En el código fuente de Nixpacks se ve el residuo de ese problema: una tabla de versiones de Node atadas a hashes de nixpkgs, donde cada versión apunta a un commit distinto.
Y aquí está el dato que cambia la decisión: el README de Nixpacks dice, textualmente, que el proyecto está en modo mantenimiento, que no está bajo desarrollo activo y que recomiendan usar Railpack como reemplazo. La versión publicada en GitHub es la 1.41.0, del 24 de octubre de 2025, y su changelog es corto: avisos de mantenimiento en la documentación, soporte de Node 24 con refactor del mapeo de versiones a archivo, y dos actualizaciones de dependencias de CI. El anuncio oficial de Railpack, en cambio, es de marzo de 2025: siete meses antes de ese release, cuando el constructor nuevo se ofrecía como opción y todavía no era el único. La conclusión para un sysadmin no es “Nixpacks está roto”, sino “Nixpacks ya no recibe el mantenimiento que necesitas y no vas a poder pedir un fix”.
Lo que Nixpacks todavía hace bien
No conviene tirarlo a la basura sin mirarlo, porque hay dos cosas que sigue haciendo mejor que las alternativas:
- Es standalone. Solo necesita el binario y un demonio Docker. No hay que instalar un framework, ni un daemon de BuildKit aparte, ni transformar la configuración a otro formato.
- Genera un Dockerfile real. Con el flag oculto
--dockerfilete imprime el Dockerfile que va a construir, para que puedas leerlo, copiarlo y editarlo. Es un puente muy útil hacia la opción manual.
Qué es Railpack y en qué se diferencia
Railpack es el sucesor oficial, también de Railway, también con licencia MIT, pero escrito desde cero en Go. El cambio de lenguaje no fue caprichoso: la biblioteca cliente de BuildKit está en Go, y la arquitectura de Railpack depende por completo de BuildKit.
La diferencia de fondo está en cómo se construye la imagen. Nixpacks generaba un Dockerfile y se lo pasaba a Docker. Railpack genera directamente el LLB (el grafo de bajo nivel de BuildKit) mediante un frontend propio, y lo resuelve contra el demonio. Eso trae tres efectos que se miden:
- Capas separadas para build y runtime. Las herramientas de compilación no sobreviven en la imagen final. En la imagen que construimos, el directorio
/nix/storede Nixpacks ocupaba 333 MB con 782 entradas, mientras que en la de Railpack el directorio/miseocupaba 203 MB. - Resolución de versiones exacta. Railpack usa Mise para resolver versiones y admite
major.minor.patch, no solo la última mayor. Además, cuando un build termina bien, bloquea las dependencias usadas, de modo que un cambio futuro en la versión por defecto no rompe tu proyecto. - Caché compartible entre entornos. Railway mide imágenes base un 38 por ciento más pequeñas en Node y un 77 por ciento más pequeñas en Python frente a Nixpacks, además de más aciertos de caché.
Railpack también usa secretos de BuildKit para las variables sensibles, así que no terminan ni en los logs del build ni en las capas de la imagen. Y su proceso completo tiene tres etapas con nombres propios: analyze (detectar el proyecto), plan (generar el plan de build en JSON) y generate (construir el grafo de BuildKit y resolverlo).
Las cuatro fases del plan de build
Antes de construir nada, ambos constructores te dejan ver el plan. Este es el paso que más problemas te ahorra, porque muestra exactamente qué comandos se van a ejecutar y qué versiones se van a instalar. Las fases que vas a ver son las mismas en los dos casos, aunque el formato del plan cambió por completo.
Con nixpacks plan el plan sale en JSON y describe las fases setup, install y build, más un bloque start aparte. En la fase setup van los paquetes Nix del lenguaje y sus dependencias de sistema, en install el gestor de paquetes, en build el comando de compilación, y en start el comando con el que arranca el contenedor. La fase setup no depende de nada, install depende de setup y build depende de install.
# Detectar primero: qué proveedores hacen match con el directorio
nixpacks detect ./mi-app
# Ver el plan completo antes de construir
nixpacks plan ./mi-app
# El plan también sale en TOML si lo prefieres
nixpacks plan ./mi-app --format toml
El plan de Nixpacks para una app Node se ve así, con la fase de setup fijando la versión de Node desde el campo engines del package.json:
{
"providers": [],
"buildImage": "ghcr.io/railwayapp/nixpacks:ubuntu-1745885067",
"variables": {
"CI": "true",
"NIXPACKS_METADATA": "node",
"NODE_ENV": "production",
"NPM_CONFIG_PRODUCTION": "false"
},
"phases": {
"build": {
"dependsOn": ["install"],
"cmds": ["npm run build"],
"cacheDirectories": ["node_modules/.cache"]
},
"install": {
"dependsOn": ["setup"],
"cmds": ["npm i"],
"cacheDirectories": ["/root/.npm"],
"paths": ["/app/node_modules/.bin"]
},
"setup": {
"nixPkgs": ["nodejs_22", "npm-9_x"],
"nixOverlays": [
"https://github.com/railwayapp/nix-npm-overlay/archive/main.tar.gz"
]
}
},
"start": { "cmd": "npm run start" }
}
El plan de Railpack tiene otra forma porque modela el grafo de capas: steps en lugar de phases, caches con tipo shared o locked, y un bloque deploy que define la imagen base, las entradas de cada capa y el comando de arranque. El deploy es donde se ve el advantage: en vez de un COPY . /app plano, cada capa declara qué incluye y qué excluye, así que node_modules se puede excluir de la capa final y el runtime queda limpio.
# Railpack: inspección rápida con salida legible
railpack info ./mi-app
# El plan en JSON, listo para guardarlo o diffearlo
railpack plan ./mi-app
# Guardar el plan a un archivo
railpack plan ./mi-app --out plan.json
# O el formato JSON para integrarlo en pipelines
railpack info ./mi-app --format json
Una diferencia práctica que conviene conocer: las variables de build de Railpack no se leen del entorno del proceso. Cosas como RAILPACK_BUILD_CMD o RAILPACK_PACKAGES hay que pasarlas explícitamente con --env, aunque estén exportadas en tu shell. Es un cambio deliberado para que un build sea reproducible en CI, pero sorprende la primera vez.
Construye la imagen de verdad
Aquí es donde las dos herramientas se separan de forma notable, no por sintaxis sino por infraestructura.
Nixpacks: solo necesitas Docker
Nixpacks invoca docker build con BuildKit habilitado y listo. No hay ningún daemon extra que levantar.
# Construir y nombrar la imagen
nixpacks build ./mi-app --name mi-app
# Ver el Dockerfile que Nixpacks va a generar, sin construir nada
nixpacks build ./mi-app --dockerfile --out /tmp/salida
# Reproducir un build a partir de un plan guardado (el flag toma el
# CONTENIDO del JSON, no la ruta del archivo)
nixpacks build ./mi-app --json-plan "$(cat plan.json)"
Ese último detalle es una trampa real: --json-plan espera el JSON en línea. Si le pasas la ruta del archivo, el error que obtienes es Error: expected value at line 1 column 1, porque el binario intenta parsear el texto plan.json como si fuera JSON. La forma correcta es la que ves arriba, con "$(cat plan.json)".
Railpack: necesitas un demonio BuildKit
Railpack habla con BuildKit directamente, así que cuando no encuentra el demonio te lo dice con un mensaje que incluye la solución:
# Levantar BuildKit como contenedor y apuntar Railpack a él
docker run --rm --privileged -d --name buildkit moby/buildkit
export BUILDKIT_HOST='docker-container://buildkit'
# Instalar Railpack (vía Mise, como recomienda su documentación)
mise use github:railwayapp/railpack@latest
# Construir
railpack build ./mi-app --name mi-app
Ese --privileged es obligatorio para el contenedor de BuildKit: necesita montar sus propios espacios de nombres para montar los caches. Si prefieres evitar el modo privilegiado, puedes usar buildkitd como servicio nativo con --oci-worker-no-process-sandbox, pero para un homelab el contenedor es la vía corta.
Railpack tiene además dos comandos de los que Nixpacks no tiene equivalente: prepare, que genera el plan y la metadata sin construir (pensado para plataformas y pipelines de CI), y schema, que imprime el JSON Schema de railpack.json para que tu editor te valide la configuración mientras la escribes.
# Generar plan y metadata sin construir la imagen
railpack prepare ./mi-app --plan-out plan.json --info-out info.json
# El esquema de configuración, para autocompletado en el editor
railpack schema > railpack.schema.json
Analiza el tamaño y el tiempo de build de los dos constructores
Las cifras que circulan en los blogs suelen comparar imágenes base de distintos proyectos, así que decidimos medir nosotros. Armamos una aplicación Node mínima pero realista, con un package.json que declara engines.node en 22, un script build y un start. La misma carpeta, la misma máquina, dos builds en frío con --no-cache, y repetimos para descartar el ruido.
| Constructor | Versión | Tiempo de build | Tamaño de imagen |
|---|---|---|---|
| Nixpacks | 1.41.0 | 67 s y 71 s | 624 MB |
| Railpack | 0.43.0 | 17 s y 18 s | 449 MB |
La diferencia de tamaño es de 27,9 por ciento, un poco menor que el 38 por ciento que Railway reporta para sus imágenes base de Node, y tiene una explicación lógica: la app de prueba es diminuta, así que el peso de la imagen está dominado por el runtime y la diferencia de las capas de build se diluye. Con una aplicación real, donde hay un node_modules grande y varios pasos de compilación, la brecha tiende a crecer.
En tiempo de build la diferencia es mucho mayor: alrededor de 70 segundos contra 18 segundos. Parte de eso es que Nixpacks tiene que resolver y descargar el nixpkgs congelado en su commit, mientras que Railpack resuelve versiones con Mise, que ya está cacheado en la imagen del builder.
Las versiones de Node que soporta cada uno
Este es el punto donde el mantenimiento se nota en la práctica. Nixpacks mantiene una tabla explícita de versiones de Node atadas a un commit de nixpkgs:
# En el código de Nixpacks (src/providers/node/mod.rs)
const AVAILABLE_NODE_VERSIONS: &[(u32, &str)] = &[
(14, "bf744fe90419885eefced41b3e5ae442d732712d"), // EOL
(16, "bf744fe90419885eefced41b3e5ae442d732712d"), // EOL
(18, "ffeebf0acf3ae8b29f8c7049cd911b9636efd7e7"),
(20, "ffeebf0acf3ae8b29f8c7049cd911b9636efd7e7"),
(22, "e6f23dc08d3624daab7094b701aa3954923c6bbb"),
(24, "23f9169c4ccce521379e602cc82ed873a1f1b52b"),
];
O sea: 14, 16, 18, 20, 22 y 24. La versión por defecto cuando no declaras nada es 18, que ya está fuera de soporte. Railpack, en cambio, resuelve con Mise y su documentación dice que soporta las versiones LTS maintained de Node y que la resolución sigue esta prioridad: variable RAILPACK_NODE_VERSION, devEngines.runtime, engines.node, .nvmrc, .node-version, mise.toml o .tool-versions, y como último recurso lts.
Un detalle interesante de Railpack: cuando detecta un proyecto Node sin gestor de paquetes fijado, lo dice. En nuestra app de prueba, que solo tenía package.json sin package-lock.json, el aviso fue:
railpack info ./mi-app
# ⚠ No node package manager detected, using npm
# → Add a `package-lock.json` for more deterministic installs
# → Applying default exclude patterns: `node_modules`
#
# Packages
# node │ 22.23.4 │ package.json > engines > node (22)
Esos avisos son accionables y te ahorran sorpresas: sin lockfile el build no es determinista, y sin .dockerignore Railpack aplica exclusiones por defecto que igual quieres customizing.
Configura las dos herramientas: Nixpacks.toml y railpack.json
Esto es lo que más sorprende a quien migra: Railpack no lee nixpacks.toml. No hay capa de compatibilidad. El archivo se llama railpack.json y vive en la raíz del proyecto, y el formato es JSON, no TOML.
El nixpacks.toml usa fases con el prefijo phases:
# nixpacks.toml
[phases.setup]
nixPkgs = ["...", "cowsay"]
[phases.build]
cmds = ["yarn run server:build"]
[start]
cmd = "yarn run server:start"
El railpack.json usa steps y deploy, y la lógica de capas es explícita:
# railpack.json
{
"$schema": "https://schema.railpack.com",
"packages": { "node": "22" },
"steps": {
"install": { "commands": ["npm ci"] },
"build": { "commands": ["...", "./mi-build.sh"] }
},
"deploy": {
"base": { "image": "ghcr.io/railwayapp/railpack-runtime:mise-2026.10.14" },
"inputs": [
{ "step": "packages:mise", "include": ["/mise/shims", "/mise/installs"] },
{ "step": "build", "include": ["."] }
],
"startCommand": "node dist/server.js"
}
}
Dos detalles que ahorran sorpresas: el "..." dentro de commands significa extender la lista en vez de reemplazarla, igual que en Nixpacks; y el campo packages fija versiones exactas de las herramientas, que es el equivalente moderno de la tabla de hashes de nixpkgs.
La prioridad de configuración, en ambos casos, va de menor a mayor: proveedor, archivo de configuración, variables de entorno, flags de CLI. Un valor no nulo en un nivel superior pisa al inferior. En Railpack las variables de entorno de build hay que pasarlas con --env, como ya contamos.
El Dockerfile gana cuando tú necesitas el control
Ninguno de los dos constructores reemplaza al Dockerfile en todos los casos. Escribe tu propio cuando necesites:
- Un usuario final sin privilegios en la imagen. Los constructores asumen que el proceso corre como root.
- Paquetes del sistema con versión exacta. Nixpacks usa los paquetes del commit de
nixpkgsque tenga congelado; si necesitas una versión específica de una librería nativa, vas a pelearte con eso. - Multi-stage estricto. Un
Dockerfilecon etapas separadas te da control exacto sobre qué archivos llegan al runtime, y nada que ver con el sandbox deshabilitado que Nixpacks necesita para que Nix funcione dentro del contenedor. - Procesos de build no estándar. Si tu build necesita una lógica condicional, un binario compilado a mano o un archivo generado,
railpack.jsonse queda corto y el Dockerfile gana.
Hay una razón de fondo que vale la pena entender. La imagen base de Nixpacks instala Nix con el sandbox deshabilitado a propósito:
# En base/debian/Dockerfile del repositorio de Nixpacks
RUN curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux --no-confirm --init none \
--extra-conf "sandbox = false" \
--extra-conf "filter-syscalls = false"
Es una concesión a la realidad de que Nix necesita montar sus propias cosas dentro del contenedor. Railpack no arrastra ese costo porque no usa Nix como gestor de paquetes.
El costo de Nix en un servidor
Este es el punto que más importa en un homelab y que casi nadie menciona. Nixpacks no solo compila con Nix: la imagen base trae Nix instalado, y esa instalación ocupa espacio en la imagen final junto con el /nix/store completo. En nuestra medición fueron 333 MB y 782 entradas del store, de los 624 MB totales. Para un servidor con espacio de disco justo, o para un registro de contenedores donde se paga por gigabytes almacenados, eso no es un detalle menor.
Railpack mueve esa responsabilidad a la imagen del builder, que no se incluye en la imagen final. El resultado medido: 449 MB en lugar de 624 MB.
Recomendación concreta
Después de probar las dos herramientas con la misma aplicación, esto es lo que haría en cada caso:
- Proyecto nuevo, aplicación estándar: Railpack, sin dudarlo. Instálalo con Mise, levanta BuildKit, y tienes una imagen reproducible con versiones exactas y un runtime limpio.
- Proyecto existente que ya usa Nixpacks en producción: no migres por panic. Nixpacks 1.41.0 funciona y construye bien. Migra cuando toques la app de todas formas, y usa la transición como excusa para fijar versiones explícitas en
package.jsono en el archivo de configuración. - Necesitas control fino de la imagen final: escribe el Dockerfile. Usa
nixpacks build --dockerfileorailpack plancomo punto de partida y edítalo a mano.
Y una advertencia práctica sobre Nixpacks: no empieces un proyecto nuevo con él. El coste no es técnico, es de mantenimiento. Vas a heredar un nixpacks.toml que nadie va a actualizar, con versiones de Node congeladas en 18 o 22 y sin parches. Railway, la empresa que lo hizo, ya lo migró a Railpack. Seguirlo es la decisión más barato.
Preguntas frecuentes
¿Nixpacks está descontinuado o sigue funcionando?
No está descontinuado: el repositorio sigue accesible, el binario se sigue distribuendo y la versión 1.41.0 funciona correctamente. Lo que el README dice es que el proyecto está en modo mantenimiento y no está bajo desarrollo activo, y que recomiendan Railpack como reemplazo. En la práctica significa que no habrá nuevas versiones de runtimes, ni fixes para problemas nuevos. El último release es del 24 de octubre de 2025 y sigue siendo el más reciente: no hay nada después.
¿Puedo usar un archivo nixpacks.toml con Railpack?
No. Railpack usa railpack.json en la raíz del proyecto y no tiene capa de compatibilidad con el formato anterior: el archivo nixpacks.toml simplemente se ignora. La migración no es mecánica porque el modelo cambió de phases a steps con capas explícitas, pero para la mayoría de las apps se resuelve en unas pocas líneas.
¿Railpack necesita Docker instalado?
Necesita un demonio BuildKit, no necesariamente Docker. Puedes usar el contenedor oficial como en el ejemplo de arriba, o un buildkitd nativo. El build de Railpack sí usa las credenciales de tu docker login para poder hacer pull y push de imágenes privadas, así que en la práctica vas a querer Docker instalado igual.
¿Qué versiones de Node soporta Nixpacks?
El código fuente declara una tabla con 14, 16, 18, 20, 22 y 24, cada una atada a un commit de nixpkgs. La versión por defecto cuando tu proyecto no declara nada es 18, que ya no recibe soporte upstream. Para fijar la tuya, usa engines.node en el package.json, un .nvmrc, un .node-version o la variable NIXPACKS_NODE_VERSION.
¿Cómo guardo y reutilizo un plan de build de Nixpacks?
Genera el plan con nixpacks plan ./mi-app > plan.json y luego pásalo como contenido, no como ruta: nixpacks build ./mi-app --json-plan "$(cat plan.json)". El flag --json-plan es global y espera el JSON en línea; si le das la ruta del archivo, el parser falla con Error: expected value at line 1 column 1.
¿Puedo ver el Dockerfile que genera Nixpacks?
Sí, con el flag oculto --dockerfile: nixpacks build ./mi-app --dockerfile --out /tmp/salida imprime el Dockerfile en la salida estándar sin construir la imagen. Railpack no tiene equivalente a propósito: su FAQ es explícita en que no genera ni va a generar Dockerfiles, porque usa LLB de bajo nivel y el punto de la herramienta es abstraerlos. Para personalizarlo, la vía es railpack.json.
