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:

Code blockPlain text
pnpm astro add node

Or you can install it directly:

Code blockPlain text
pnpm add @astrojs/node

Then, inside astro.config.mjs, the important configuration looks like this:

Code blockPlain text
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.

Code blockPlain text
output: "server"

tells Astro that the application should be rendered from the server.

Then:

Code blockPlain text
adapter: node({
  mode: "standalone",
})

makes Astro generate a Node.js application that can start independently.

Finally, we are working with:

Code blockPlain text
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:

Code blockPlain text
pnpm build

Astro generates the:

Code blockPlain text
dist/

directory.

But when using Node in standalone mode, inside it we get something similar to this:

Code blockPlain text
dist/
├── client/
│   └── ...
│
└── server/
    ├── entry.mjs
    └── ...

The important file is:

Code blockPlain text
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:

Code blockPlain text
HOST=0.0.0.0 PORT=3000 node ./dist/server/entry.mjs

Then open:

Code blockPlain text
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:

Code blockPlain text
{
  "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:

Code blockPlain text
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:

Code blockPlain text
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:

Code blockPlain text
Build command:
pnpm build

And because Astro generates the build inside dist, I configured:

Code blockPlain text
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:

Code blockPlain text
dist/server/entry.mjs

So it seems completely logical to configure Hostinger like this:

Code blockPlain text
Entry file:
dist/server/entry.mjs

But in my setup, that was incorrect.

Why?

Because I had already configured:

Code blockPlain text
Output directory:
dist

Hostinger was already treating dist as the root directory where it should look for the entry file.

So if I used:

Code blockPlain text
dist/server/entry.mjs

it was conceptually trying to resolve something similar to:

Code blockPlain text
dist/dist/server/entry.mjs

The configuration that finally worked was:

Code blockPlain text
Output directory:
dist

Entry file:
server/entry.mjs

In other words:

Code blockPlain text
- 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:

Code blockPlain text
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:

Code blockPlain text
Build command:
pnpm build

Output directory:
dist

Entry file:
server/entry.mjs

Port:
3000

And the generated build should contain:

Code blockPlain text
dist/
├── client/
└── server/
    └── entry.mjs

The relationship you should keep in mind is simply:

Code blockPlain text
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:

Code blockPlain text
pnpm build

and confirm that this file actually exists:

Code blockPlain text
dist/server/entry.mjs

Then I would try running it directly:

Code blockPlain text
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:

Code blockPlain text
output: "server"

and:

Code blockPlain text
adapter: node({
  mode: "standalone",
})

Then I would move on to Hostinger.

Especially these two settings:

Code blockPlain text
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:

Code blockPlain text
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:

Code blockPlain text
server/entry.mjs

instead of:

Code blockPlain text
dist/server/entry.mjs

The useful lesson was remembering a distinction that is easy to forget when working with modern applications:

Code blockPlain text
Successful build ≠ application running successfully

Astro was doing its job correctly.

Code blockPlain text
Code
   ↓
Astro Build
   ↓
dist/

But someone still has to execute that application:

Code blockPlain text
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:

Code blockPlain text
Output directory:
dist

Entry file:
server/entry.mjs

It may look like a tiny detail.

In my case, it was the whole problem.