Build an auto-deploying static blog: Astro + a private content repo + OVH (GitHub Actions over SFTP)
Published 21 June 2026
- date
- env
- linux CI: GitHub Actions ubuntu-latest; host: OVH shared web hosting (Apache) macos Dev machine: macOS with Node 22+, npm, git
- tags
Overview
This is the build sheet for a 100% static blog where:
- the site (Astro app + CI) lives in one GitHub repo;
- the content (your how-tos, as plain Markdown) lives in a separate private GitHub repo;
- a push to the content repo auto-cleans, builds and deploys the site to OVH shared hosting over SFTP — no servers, no database, near-zero attack surface.
The result: your day-to-day is “write a Markdown file, push it, done.” Everything else is automated. The same recipe works for any client who wants a fast, cheap, low-maintenance technical blog or knowledge base.
The moving parts and how data flows:
How publishing actually works
A single push to the content repo triggers the whole chain:
Prerequisites
- Node 22.12+, npm, git, and a GitHub account.
- A domain (here managed at OVH) and an OVH Web Hosting plan (the cheapest static plan is fine; avoid VPS — you don’t want to run a server).
- A static-site generator. This guide uses Astro with Pagefind search and Expressive Code,
but the deployment half (steps 3–8) is generator-agnostic — anything that outputs a
dist/folder works.
1. Create the site repository
Scaffold the Astro app (or your generator of choice) and push it to a GitHub repo, e.g.
you/myblog. The only hard requirement for what follows: npm run build must produce a
self-contained static dist/ directory.
npm create astro@latest myblogcd mybloggit init && git add -A && git commit -m "Initial site"gh repo create you/myblog --private --source=. --push2. Create the private content repository
Create a second, private repo — e.g. you/content — that will hold nothing but your
Markdown files. Keeping content separate means: the site repo can be public without exposing
drafts, and writers never touch the app code.
Point your build at a content folder that is gitignored in the site repo (the content is pulled in at build time, never committed there):
echo "src/content/posts/" >> .gitignore3. Provision OVH Web Hosting
In the OVH panel:
- Order a Web Hosting plan and attach it to your domain. Choose “No module” (no WordPress/etc. — you ship your own static files).
- Open the hosting → Multi-site tab → Add a domain or sub-domain:
- leave the sub-domain field empty to register the apex domain;
- tick “also create the www sub-domain”;
- set the root folder to
www(this is the web root:/home/<user>/www); - let OVH update the DNS automatically.
- Open SSL certificates → enable the Let’s Encrypt certificate for the domain.
OVH offers plain FTP (port 21) or SFTP (port 22) but rejects FTPS — remember this, it decides your deploy transport (step 6). Grab the FTP-SSH host, username and password from the FTP - SSH tab; the home directory is
/home/<user>and the web root is itswww/subfolder.
Sanity-check the credentials from your machine before automating anything:
sftp <user>@<host>lscd www4. Wire content into the build (clone + clean)
In the site repo, add a script that clones the content repo into the gitignored content
folder at build time. If you author in Obsidian, your raw files often carry a duplicated H1,
a meta block, or inconsistent front-matter — so clean them in the same pass (strip the H1/meta,
normalise tags, drop non-article files like the repo README). Run this both locally and in CI.
node scripts/content-sync.mjs # GH_PAT + repo from env -> clone into content folder, then cleanThe cleaning makes the system forgiving: you can push raw Markdown and the site still renders it correctly, and one malformed file is dropped with a warning instead of breaking the build.
5. Configure GitHub secrets
In the site repo → Settings → Secrets and variables → Actions → Secrets:
| Secret | Value |
|---|---|
GH_PAT | fine-grained PAT, read-only Contents on the content repo |
FTP_HOST | OVH FTP-SSH host (e.g. ftp.clusterXXX.hosting.ovh.net) |
FTP_USER | OVH FTP-SSH username |
FTP_PASSWD | OVH FTP-SSH password |
Optional Variables: FTP_REMOTE_DIR (absolute web root, e.g. /home/<user>/www) and the
content repo name.
6. The deploy workflow
On push (and on a repository_dispatch from the content repo), the workflow installs, pulls +
cleans content, builds, then mirrors dist/ to OVH over SFTP with lftp. Use lftp mirror
rather than an FTP action: OVH rejects FTPS, and lftp over SFTP creates remote directories
correctly and deletes stale files.
name: Build & deployon: push: branches: [main] workflow_dispatch: repository_dispatch: types: [content-updated]jobs: build-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22, cache: npm } - run: npm ci - name: Pull + clean content env: GH_PAT: ${{ secrets.GH_PAT }} run: npm run content:sync - run: npm run build - name: Deploy to OVH over SFTP env: FTP_HOST: ${{ secrets.FTP_HOST }} FTP_USER: ${{ secrets.FTP_USER }} FTP_PASSWD: ${{ secrets.FTP_PASSWD }} REMOTE_DIR: ${{ vars.FTP_REMOTE_DIR || '/home/USER/www' }} run: | sudo apt-get update -qq && sudo apt-get install -y -qq lftp lftp "sftp://$FTP_HOST" <<EOF set sftp:auto-confirm yes user "$FTP_USER" "$FTP_PASSWD" mirror --reverse --delete --parallel=4 ./dist/ "$REMOTE_DIR" bye EOFTip: gate the deploy step on the secrets existing (
if:on a check step) so the very first runs — before you’ve added secrets — go green as build-only instead of failing.
7. Auto-rebuild on publish
So that editing content triggers a deploy, add a tiny notify workflow to the content
repo. It needs a fine-grained PAT (SITE_DISPATCH_TOKEN) with Contents: read and write on
the site repo (the dispatch API is gated behind Contents-write).
name: Notify siteon: push: branches: [main]jobs: dispatch: runs-on: ubuntu-latest steps: - run: | curl -sf -X POST \ -H "Authorization: Bearer ${{ secrets.SITE_DISPATCH_TOKEN }}" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/you/myblog/dispatches \ -d '{"event_type":"content-updated"}'8. DNS & SSL
If you let OVH manage DNS in step 3, the apex and www A/AAAA records point at the hosting
cluster automatically. The Let’s Encrypt certificate is auto-renewed by OVH (Let’s
Encrypt certs are 90-day by design; OVH reissues ~30 days before expiry) as long as the domain
stays attached in Multi-site — so there’s nothing recurring to do.
How a how-to reaches production (new / updated / removed)
Because the build clones a fresh copy of the content repo every time and the deploy uses
mirror --delete, the three change types all “just work”:
Gotchas we hit (and the fixes)
- OVH rejects FTPS (
500 This security scheme is not implemented). Don’t use an FTP/FTPS action — deploy over SFTP (port 22) withlftp. sftp put -r ./dist/*fails (dest open .../index.html: No such file or directory) — the recursive wildcard mishandles top-level files.lftp mirrordoesn’t have this problem.- Raw Obsidian files break the schema (e.g.
tags: a ; b ; cas a string, or a duplicated# H1). Clean them at build time instead of hand-fixing each file. - The content repo’s own
README.mdgets picked up as a “post” and fails validation — excludeREADME/template/index files from the content glob. - “It deployed but the site is unchanged” usually means no run was triggered after you added the secrets. Re-run the workflow; secrets only apply to runs started after they exist.
- OVH shows “Site en construction” = the domain isn’t attached in Multi-site yet, or the
root folder isn’t
www.
Verify
curl -sS -o /dev/null -w "%{http_code}\n" https://your-domain/curl -sS https://your-domain/ | grep -o "<title>[^<]*</title>"A 200 plus your own <title> (not OVH’s “Site en construction”) means the full chain — clone,
clean, build, SFTP mirror, DNS, SSL — is working end to end.
Sources
- Astro documentation — https://docs.astro.build/
- Pagefind (static search) — https://pagefind.app/
- lftp manual (
mirrorover SFTP) — https://lftp.yar.ru/ - rehype-mermaid (build-time diagram SVG) — https://github.com/remcohaszing/rehype-mermaid
- OVH — SSL certificates on web hosting — https://help.ovhcloud.com/csm/en-web-hosting-ssl-certificates