5 min read

Astro on shared hosting: no Node server, no problem

How this blog gets from astro build to a Swiss shared webhosting with rsync, a single .htaccess and zero running processes.

Every few months someone in a Slack I’m in asks the same question: “Can I host an Astro site on normal shared hosting, or do I need Vercel?” The answer is yes, you can, and for a blog it’s arguably the better option. This site runs on a plain webhosting with PHP and LiteSpeed. There’s no Node process on the server, no container, no edge function. This post is the whole setup, including the three things I got wrong the first time.

What “static” actually buys you

Astro’s default output mode is static. astro build walks every page, runs your components once, and writes HTML files into dist/. What’s left is a folder you could open from a USB stick. That has consequences that are easy to underrate:

  • Nothing to patch. There’s no runtime on the server that can have a CVE next Tuesday.
  • Nothing to scale. LiteSpeed serves files from disk faster than any framework renders them.
  • Deploys are atomic enough. Copy the new folder, delete what’s gone, done.

The flip side: anything that needs to run per request is off the table. No SSR, no API routes in Astro itself, no middleware. If you add @astrojs/node as an adapter, you’ve turned your site back into a server, and that server has nowhere to run on shared hosting. For a blog that’s fine. For a contact form, a 30-line PHP script next to the HTML does the job (a post for another day).

My hosting does have Node installed, by the way, but only as a command-line tool. I don’t build on the server either: builds under a shared CPU quota are slow, and I want the exact node_modules I tested locally.

The Astro config

The config is short, and every line exists for a reason:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://r2-02-blog.zicoxusi.cyon.site',
  trailingSlash: 'always',
  build: { format: 'directory' },
  integrations: [sitemap()],
});

site is needed for the sitemap, canonical URLs, feeds and Open Graph images, all of which need absolute URLs. build.format: 'directory' is the default, but I set it explicitly because it matters: /posts/foo/ is written as posts/foo/index.html. The web server finds that file without a single rewrite rule. The alternative, format: 'file', writes posts/foo.html, and then you need rewrites to hide the extension.

trailingSlash: 'always' makes Astro’s dev server behave like production. Without it, /about works in dev and then 301s in production, and you only notice when a link checker yells at you.

Getting the files there

The deployment is one command:

npm run build
rsync -az --delete \
  --chmod=Dgo+rx,Fgo+r \
  --exclude '.well-known/' \
  ./dist/ user@host:~/public_html/blog/

Three flags do the heavy lifting:

  1. --delete removes files on the server that are no longer in dist/. Without it, a renamed post lives on forever at its old URL, and old hashed assets pile up. This is only safe because the target directory contains nothing but build output. Never point this at a folder that also holds uploads or a CMS.
  2. --chmod=Dgo+rx,Fgo+r fixes permissions. -a preserves the local modes, and if your build ran in a directory with 700 permissions, the document root inherits them and the web server answers every request with a 404. Ask me how I know.
  3. --exclude '.well-known/' leaves Let’s Encrypt’s validation files alone, so certificate renewals don’t break after a deploy.

I did briefly want git push deploys, Heroku-style. You can set up a bare repo with a post-receive hook on most hosts, but then the hook has to build the site, which brings back the “build on the server” problem. If you want push-to-deploy, let CI build and rsync. For a one-person blog, npm run deploy on my laptop is honestly enough.

One .htaccess to rule them all

LiteSpeed reads Apache’s .htaccess, and I keep mine in public/ so Astro copies it into every build. It’s versioned with the site instead of living only on the server, where I’d forget it exists.

ErrorDocument 404 /404.html

<IfModule mod_headers.c>
  Header set Cache-Control "public, max-age=0, must-revalidate"
  <FilesMatch "\.[A-Za-z0-9_-]{8}\.(css|js|woff2?|svg|png)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>
</IfModule>

ErrorDocument points at the 404 page Astro generates from src/pages/404.astro. The server still sends a real 404 status, which matters for search engines: a pretty error page served with 200 is a “soft 404” and gets indexed.

The cache rules deserve their own post (and got one), but the short version is: files Astro fingerprints, like index.B3kd9aZ_.css, never change, so they get a year and immutable. HTML must always be revalidated, or a visitor’s browser keeps an old page pointing at a CSS file the last deploy deleted.

What I did not put in .htaccess: the HTTPS redirect. On my host that’s a setting on the site itself, and a rewrite rule on top of it only adds a second redirect hop.

The three mistakes

For the record, here’s what broke on the first deploy:

  • Permissions. Covered above. Every page was a 404 until I added --chmod.
  • A leftover index.html in the web root. The hosting panel creates a placeholder page on new subdomains. It got overwritten, but a default.html next to it didn’t, until --delete took care of it.
  • Hard-coded http://localhost:4321 in Open Graph tags. I built the OG URL from Astro.url in dev and forgot to set site. Always build absolute URLs from Astro.site.

Checking that it worked

After every deploy I run a tiny smoke test instead of trusting the browser, which caches too eagerly to tell you anything:

for p in / /posts/ /about/ /rss.xml /does-not-exist; do
  printf '%-18s ' "$p"
  curl -s -o /dev/null -w '%{http_code} %{content_type}\n' "https://example.ch$p"
done
curl -sI https://example.ch/_astro/index.B3kd9aZ_.css | grep -i cache-control

Five status codes, one header, done in a second. If /does-not-exist doesn’t say 404, something is wrong with the ErrorDocument line. If the CSS doesn’t say immutable, the FilesMatch pattern didn’t match your hash format.

Is it worth it?

For a blog, a portfolio or documentation: yes. My hosting costs less per month than a single Vercel Pro seat, it’s in Switzerland, email comes with it, and nothing on the server can fall over at 3 a.m. because nothing is running. The moment you need per-user pages or a database on every request, reach for something else. Until then, dist/ and rsync are hard to beat.