← All posts

Deploying cleanly to shared hosting

Shared hosting has a bad reputation among developers. No Docker, no Node, a limited shell, and that old habit of dropping files over FTP "until something better comes along". Yet for a marketing site or a static blog, it's often the most sensible option: a few francs a month, a managed server, and zero infrastructure to maintain. The problem isn't the hosting. It's how people deploy to it.

This site runs on Hostinger shared hosting. Here is, without embellishment, how it goes live — and the rules I set myself so it stays clean over time.

Rule 1: sources never live in the web folder

On shared hosting, everything inside public_html/ is served to the whole world. That's the first mistake I find on sites I'm asked to take over: the .git folder, the original Markdown files, a config.bak, sometimes a .env — all reachable if you type the right URL.

My rule is simple: public_html/ contains only the build output. The blog's sources — Markdown articles, the generator script, the editorial backlog — live in a sibling folder, blog-src/, outside the web tree. It's versioned with git (available on the Hostinger shell), but that repository is never exposed.

domains/stackshaka.com/
  blog-src/        ← sources, git, never served
    content/blog/fr/*.md
    content/blog/en/*.md
    build.php
  public_html/     ← build output only
    fr/blog/
    en/blog/
    .htaccess

On top of that, the .htaccess denies access to every hidden file and to a list of sensitive extensions (.bak, .env, .md, .log, .sql…). If a file ends up in the wrong place by mistake, it still won't be served. Belt and braces.

Rule 2: a reproducible build, using what the server has

The Hostinger shell has no Node. I could have built the site on my machine and uploaded the result, but I wanted the generation to run directly on the server — from a cron job or an automated agent — without depending on my laptop.

So I wrote the generator in PHP, because that's what's available (version 8.3). A single file, build.php, that reads the Markdown, parses the frontmatter and writes the HTML pages, the index pages and the RSS feeds. No dependencies, no node_modules blowing through a shared host's inode quota (a real risk: an average JavaScript project easily means 30,000 files).

cd blog-src && php build.php

One command, the same result every time. The build reuses the existing site's stylesheet and self-hosted fonts: the blog introduces no new dependency, and the strict CSP stays untouched.

Rule 3: uploads go through SSH, never FTP

Plain FTP sends your password unencrypted. FTP of any kind has you dropping files one by one, with no idea what actually changed. Hostinger provides SSH access on its shared plans; I use it for everything.

In practice, a small Python script wraps the connection: it reads credentials from a local .env file (never versioned, never uploaded), runs a remote command or sends a file. Deploying a new article comes down to three steps: upload the two .md files (FR and EN) into blog-src/content/, run php build.php remotely, then commit the sources to git on the server. For a full site, rsync over SSH does the same job, transferring only the differences.

The tool isn't what matters. What matters is that every release is a command, not a series of clicks.

Rule 4: a cache policy you can explain

This is where most shared-hosted sites get it wrong, one way or the other. Either no caching at all and every visit reloads everything, or a one-year max-age on everything, so a CSS fix takes weeks to reach visitors.

My policy fits in two rules, in the .htaccess:

  • HTML pages are always revalidated (no-cache, must-revalidate). A corrected article is visible immediately.
  • Static assets (CSS, JS, images, fonts) are cached for seven days, with stale-while-revalidate. No immutable, no one-year cache: my filenames aren't content-hashed, so a longer duration would freeze updates for returning visitors.

The CSS is loaded with a version parameter derived from the file's modification date. If I change the stylesheet, the build changes the URL and the cache is bypassed without me thinking about it. Brotli and gzip compression are on for text, off for already-compressed images.

Rule 5: preview without publishing

A static site has no staging environment by default. I built a minimal one: php build.php --preview <slug> renders an article, even a draft, into a _preview/ subfolder with a noindex tag. The blog index, the RSS feed and the sitemap are left alone. I proofread the article at its real URL, in its real design, then publish — or don't. On publication, the preview folder is deleted.

Rule 6: check after every release

A deployment isn't finished until it's verified. My list, every time:

  • The published page contains no leftover noindex.
  • It's listed in the index and in the RSS feed.
  • The sitemap.xml has been updated and is still valid XML (a php -r 'simplexml_load_file(...)' is enough).
  • The security headers are unchanged. A curl -I on the page compares the CSP against the reference.

The .htaccess has a backup, and so does the sitemap, before every change. It sounds excessive for a blog. It's what lets an agent publish on its own without me worrying.

What it changes

None of this requires a dedicated server. These are habits, not tools: keep sources and output apart, build by script, deploy by command, make the cache explicit, verify. Applied to shared hosting at a few francs a month, they give you a site that's as clean to ship as far more expensive infrastructure.

If your current site is deployed "by hand" and you're not sure what's lying around in its web folder, that's exactly the kind of thing I put right in a day. Let's talk.

Got a project in mind? Let’s talk.

Get in touch