Recently, I needed to deploy a project built with Astro using Server-Side Rendering (SSR) on Hostinger.
At first, it did not seem particularly complicated. The project worked correctly in local development, the repository was already on GitHub, and Hostinger allows you to deploy Node.js applications directly from a repository.
The problem showed up afterward.
Hostinger completed the build successfully, Astro did not report any errors, and everything seemed to indicate that the deployment had finished correctly.
But when I opened the domain, I got:
403 Forbidden
That was where the real problem started.
After several tests, I eventually discovered that the issue was not with the Astro build itself. The problem was how Hostinger was trying to start the application after building it.
And that small detail ended up being the difference between having an application that looked deployed and actually having Astro SSR running correctly.
First, Astro needs to generate a Node.js application
If you are using Astro in static mode, you probably do not need any special configuration for deployment.
In my case, however, I needed SSR.
That means Astro does not only need to generate HTML, CSS, and JavaScript. It also needs to generate a server that Node.js can execute.
For that, we need the official Node adapter:
pnpm astro add node Or you can install it directly:
pnpm add @astrojs/node Then, inside astro.config.mjs, the important configuration looks like this:
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone",
}),
server: {
host: true,
port: 3000,
},
}); There are really three things that matter here.
output: "server" tells Astro that the application should be rendered from the server.
Then:
adapter: node({
mode: "standalone",
}) makes Astro generate a Node.js application that can start independently.
Finally, we are working with:
port: 3000 because Hostinger expects Node.js applications to listen on port 3000.
There is a small technical detail here: server.port is mainly part of Astro's development and preview server configuration. The server generated by @astrojs/node can also receive its port at runtime.
The important thing is not memorizing every property.
The important thing is understanding that Hostinger needs a Node.js application that can actually start and handle requests, not just a folder full of static files.
What does Astro actually generate?
This was the point where I started understanding what was really happening.
When we run:
pnpm build Astro generates the:
dist/ directory.
But when using Node in standalone mode, inside it we get something similar to this:
dist/
├── client/
│ └── ...
│
└── server/
├── entry.mjs
└── ... The important file is:
dist/server/entry.mjs That file is basically the entry point of our Node.js application.
You can even test it locally before uploading anything to Hostinger:
HOST=0.0.0.0 PORT=3000 node ./dist/server/entry.mjs Then open:
http://localhost:3000 If the application works from there, that is a pretty strong indication that Astro has already done its job correctly.
I also prefer to leave an explicit start script inside 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"
}
} Then I can test the production behavior with:
pnpm build
pnpm start If that works, the next problem usually is not Astro itself.
It is how the platform is executing what Astro built.
Then came Hostinger
With the project ready, I connected the GitHub repository to a Node.js application inside Hostinger.
The flow is pretty simple:
Astro Project
↓
GitHub
↓
Hostinger
↓
pnpm build
↓
dist/
↓
Node runs Astro Hostinger downloads the repository, installs the dependencies, and runs the build command.
In my case:
Build command:
pnpm build And because Astro generates the build inside dist, I configured:
Output directory:
dist Up to this point, everything looked correct.
In fact, the build completed successfully.
And that was exactly why the 403 Forbidden error was confusing.
The natural reaction is:
If the build completed successfully, why is the application not working?
The answer is that building an application and running an application are two different things.
The build was fine.
The problem was in the next step.
The small detail that was causing my 403
This was the actual solution.
Astro had generated the server at:
dist/server/entry.mjs So it seems completely logical to configure Hostinger like this:
Entry file:
dist/server/entry.mjs But in my setup, that was incorrect.
Why?
Because I had already configured:
Output directory:
dist Hostinger was already treating dist as the root directory where it should look for the entry file.
So if I used:
dist/server/entry.mjs it was conceptually trying to resolve something similar to:
dist/dist/server/entry.mjs The configuration that finally worked was:
Output directory:
dist
Entry file:
server/entry.mjs In other words:
- dist/server/entry.mjs
+ server/entry.mjs After making that change, the application started working correctly.
That was my 403.
I did not have an Astro error.
I did not have a build error.
I had a problem telling Hostinger where my Node.js server actually started.
This is what my final configuration looked like
If you arrived here because you have an Astro SSR project that works locally but Hostinger is giving you trouble, this is the configuration that worked for me.
In 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,
},
}); In Hostinger:
Build command:
pnpm build
Output directory:
dist
Entry file:
server/entry.mjs
Port:
3000 And the generated build should contain:
dist/
├── client/
└── server/
└── entry.mjs The relationship you should keep in mind is simply:
Hostinger
↓
Output directory: dist
↓
server/entry.mjs
↓
Astro Node Server
↓
SSR running That is really the core of the whole problem.
A few things I would check before going crazy with the deployment
After dealing with this, there are a few things I would verify before randomly changing configuration values.
First, I would run locally:
pnpm build and confirm that this file actually exists:
dist/server/entry.mjs Then I would try running it directly:
HOST=0.0.0.0 PORT=3000 node ./dist/server/entry.mjs If that works, Astro is probably fine.
I would also make sure I have:
output: "server" and:
adapter: node({
mode: "standalone",
}) Then I would move on to Hostinger.
Especially these two settings:
Output directory
Entry file Because even though they look like minor details, they were exactly what ended up breaking my deployment.
I would also check the Node.js version used by Hostinger and compare it with the one I use locally:
node -v What I actually learned from this error
I think the most useful part of this problem was not discovering that I had to write:
server/entry.mjs instead of:
dist/server/entry.mjs The useful lesson was remembering a distinction that is easy to forget when working with modern applications:
Successful build ≠ application running successfully Astro was doing its job correctly.
Code
↓
Astro Build
↓
dist/ But someone still has to execute that application:
dist/
↓
Node.js
↓
entry.mjs
↓
HTTP Requests In this case, that someone was Hostinger.
And Hostinger simply needed me to correctly tell it where the server generated by Astro was located.
So if you are deploying Astro with SSR on Hostinger and you get a 403 Forbidden even though the build finishes successfully, I would not start by changing your entire project.
I would first verify the generated server.
Then I would check this:
Output directory:
dist
Entry file:
server/entry.mjs It may look like a tiny detail.
In my case, it was the whole problem.