Hace poco necesitaba desplegar en Hostinger un proyecto desarrollado con Astro utilizando Server-Side Rendering (SSR).
En principio no parecía algo complicado. El proyecto funcionaba correctamente en local, el repositorio estaba en GitHub y Hostinger permite desplegar aplicaciones Node.js directamente desde un repositorio.
El problema apareció después.
Hostinger hacía el build correctamente, Astro no mostraba errores y todo parecía indicar que el deployment había terminado bien.
Pero al entrar al dominio obtenía:
403 Forbidden
Ahí fue donde empezó realmente el problema.
Después de varias pruebas terminé descubriendo que el fallo no estaba en el build de Astro. El problema estaba en cómo Hostinger estaba intentando arrancar la aplicación después de construirla.
Y ese pequeño detalle terminó siendo la diferencia entre tener una aplicación aparentemente desplegada y tener realmente Astro SSR funcionando.
Primero: Astro tiene que generar una aplicación Node
Si estás trabajando con Astro en modo estático, probablemente no necesitas nada especial para desplegarlo.
Pero en mi caso necesitaba SSR.
Eso significa que Astro no solamente tiene que generar HTML, CSS y JavaScript. También tiene que generar un servidor que pueda ejecutar Node.js.
Para eso necesitamos el adapter oficial:
pnpm astro add node o directamente:
pnpm add @astrojs/node Después, en astro.config.mjs, la configuración importante queda así:
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone",
}),
server: {
host: true,
port: 3000,
},
}); Aquí realmente hay tres cosas que nos interesan.
output: "server" le indica a Astro que nuestra aplicación será renderizada desde el servidor.
Después:
adapter: node({
mode: "standalone",
}) hace que Astro genere una aplicación Node que puede arrancar independientemente.
Y finalmente estamos trabajando con:
port: 3000 porque Hostinger espera que sus aplicaciones Node.js estén disponibles en ese puerto.
Hay un pequeño matiz aquí: server.port pertenece principalmente a la configuración del servidor de Astro durante desarrollo y preview. El servidor generado por @astrojs/node también puede recibir el puerto en runtime.
La idea importante no es memorizar cada propiedad.
Es entender que Hostinger necesita recibir una aplicación Node que pueda arrancar y escuchar solicitudes, no simplemente una carpeta llena de archivos estáticos.
¿Qué genera realmente Astro?
Aquí fue donde comencé a entender mejor lo que estaba ocurriendo.
Cuando ejecutamos:
pnpm build Astro genera la carpeta:
dist/ Pero al utilizar Node con standalone, dentro encontramos algo parecido a esto:
dist/
├── client/
│ └── ...
│
└── server/
├── entry.mjs
└── ... El archivo importante es:
dist/server/entry.mjs Ese archivo es, básicamente, el punto de entrada de nuestra aplicación Node.
Podemos incluso probarlo localmente antes de subir nada a Hostinger:
HOST=0.0.0.0 PORT=3000 node ./dist/server/entry.mjs Y abrir:
http://localhost:3000 Si nuestra aplicación funciona desde ahí, tenemos una señal bastante buena de que Astro ya hizo correctamente su trabajo.
Personalmente, también prefiero dejar un script explícito en package.json:
{
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "HOST=0.0.0.0 PORT=3000 node ./dist/server/entry.mjs"
}
} Entonces puedo comprobar el comportamiento de producción simplemente con:
pnpm build
pnpm start Si eso funciona, el siguiente problema ya no suele estar en Astro.
Está en cómo la plataforma está ejecutando lo que Astro construyó.
Entonces llegó Hostinger
Con el proyecto preparado, conecté el repositorio de GitHub a una aplicación Node.js dentro de Hostinger.
El flujo es bastante directo:
Proyecto Astro
↓
GitHub
↓
Hostinger
↓
pnpm build
↓
dist/
↓
Node ejecuta Astro Hostinger descarga el repositorio, instala las dependencias y ejecuta nuestro comando de build.
En mi caso:
Build command:
pnpm build Y como Astro genera los archivos dentro de dist, configuré:
Output directory:
dist Hasta aquí todo parecía estar bien.
De hecho, el build terminaba correctamente.
Y precisamente por eso el 403 Forbidden era confuso.
Porque naturalmente uno piensa:
Si el build terminó correctamente, ¿por qué mi aplicación no funciona?
La respuesta es que construir una aplicación y ejecutar una aplicación son dos procesos diferentes.
El build estaba bien.
El problema estaba en el siguiente paso.
El pequeño detalle que me estaba causando el 403
Aquí estuvo realmente la solución.
Astro había generado nuestro servidor en:
dist/server/entry.mjs Entonces parece completamente lógico colocar en Hostinger:
Entry file:
dist/server/entry.mjs Pero en mi configuración eso era incorrecto.
¿Por qué?
Porque yo ya había indicado:
Output directory:
dist Hostinger estaba tomando dist como la raíz desde donde debía buscar el archivo de entrada.
Por lo tanto, si colocaba:
dist/server/entry.mjs conceptualmente estaba intentando resolver algo parecido a:
dist/dist/server/entry.mjs La configuración que finalmente funcionó fue:
Output directory:
dist
Entry file:
server/entry.mjs Es decir:
- dist/server/entry.mjs
+ server/entry.mjs Y después de hacer ese cambio, la aplicación comenzó a ejecutarse correctamente.
Ese era mi 403.
No tenía un error en Astro.
No tenía un error durante el build.
Tenía un problema indicando a Hostinger dónde comenzaba realmente mi servidor Node.
Así terminó quedando mi configuración
Si llegaste aquí porque tienes un proyecto Astro SSR que funciona localmente pero Hostinger te está complicando la vida, esta es la combinación que me funcionó.
En Astro:
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone",
}),
server: {
host: true,
port: 3000,
},
}); En Hostinger:
Build command:
pnpm build
Output directory:
dist
Entry file:
server/entry.mjs
Port:
3000 Y el resultado del build debería contener:
dist/
├── client/
└── server/
└── entry.mjs La relación que debemos tener en la cabeza es simplemente esta:
Hostinger
↓
Output directory: dist
↓
server/entry.mjs
↓
Astro Node Server
↓
SSR funcionando Eso es realmente todo el núcleo del problema.
Algunas cosas que revisaría antes de volverme loco con el deploy
Después de pasar por esto, hay unas cuantas comprobaciones que haría antes de empezar a cambiar configuraciones aleatoriamente.
Primero ejecutaría localmente:
pnpm build y comprobaría que realmente existe:
dist/server/entry.mjs Después intentaría ejecutar directamente:
HOST=0.0.0.0 PORT=3000 node ./dist/server/entry.mjs Si eso funciona, Astro probablemente está bien.
También comprobaría que tengo:
output: "server" y:
adapter: node({
mode: "standalone",
}) Después revisaría Hostinger.
Especialmente estas dos propiedades:
Output directory
Entry file Porque aunque parecen configuraciones menores, fueron precisamente las que terminaron rompiendo mi deployment.
También revisaría la versión de Node utilizada por Hostinger y la compararía con la que utilizo localmente:
node -v Lo que realmente aprendí de este error
Creo que lo más interesante de este problema no fue descubrir que tenía que escribir:
server/entry.mjs en lugar de:
dist/server/entry.mjs Lo realmente útil fue recordar una diferencia que cuando trabajamos con aplicaciones modernas a veces olvidamos:
Build exitoso ≠ aplicación ejecutándose correctamente Astro estaba haciendo perfectamente su trabajo.
Código
↓
Astro Build
↓
dist/ Pero después alguien todavía tiene que ejecutar esa aplicación:
dist/
↓
Node.js
↓
entry.mjs
↓
HTTP Requests En este caso ese alguien era Hostinger.
Y Hostinger simplemente necesitaba que yo le indicara correctamente dónde estaba el servidor que Astro había generado.
Por eso, si estás desplegando Astro con SSR en Hostinger y te encuentras con un 403 Forbidden aunque tu build termina correctamente, no empezaría modificando todo el proyecto.
Primero comprobaría el servidor generado.
Y después miraría esto:
Output directory:
dist
Entry file:
server/entry.mjs Puede parecer un detalle pequeño.
En mi caso, era todo el problema.