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

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:

GitHub Actions runner

GitHub

Authoring

push

repository_dispatch

lftp mirror, SFTP

local notes (Obsidian)

raw .md

content repo (private)

raw Markdown

site repo (Astro + CI)

clone + auto-clean

astro build + pagefind

OVH web hosting

/www over SFTP

https://your-domain

How publishing actually works

A single push to the content repo triggers the whole chain:

OVH /wwwsite ActionsNotify workflowcontent repoOVH /wwwsite ActionsNotify workflowcontent repolive in ~1-2 minutesYougit push (new / edited .md)on pushrepository_dispatch (content-updated)clone with token + auto-cleanastro build + pagefind indexlftp mirror --delete (SFTP)You

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.

Terminal window
npm create astro@latest myblog
cd myblog
git init && git add -A && git commit -m "Initial site"
gh repo create you/myblog --private --source=. --push

2. 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):

Terminal window
echo "src/content/posts/" >> .gitignore

3. Provision OVH Web Hosting

In the OVH panel:

  1. Order a Web Hosting plan and attach it to your domain. Choose “No module” (no WordPress/etc. — you ship your own static files).
  2. 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.
  3. 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 its www/ subfolder.

Sanity-check the credentials from your machine before automating anything:

Terminal window
sftp <user>@<host>
ls
cd www

4. 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.

Terminal window
node scripts/content-sync.mjs # GH_PAT + repo from env -> clone into content folder, then clean

The 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:

SecretValue
GH_PATfine-grained PAT, read-only Contents on the content repo
FTP_HOSTOVH FTP-SSH host (e.g. ftp.clusterXXX.hosting.ovh.net)
FTP_USEROVH FTP-SSH username
FTP_PASSWDOVH 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 & deploy
on:
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
EOF

Tip: 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 site
on:
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”:

new file

edited file

removed file

mirror

mirror

mirror --delete

Change in content repo

fresh clone has it

fresh clone has new text

fresh clone lacks it

build emits a new page

build emits updated page

no page built

added on OVH

overwritten on OVH

removed from OVH

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) with lftp.
  • sftp put -r ./dist/* fails (dest open .../index.html: No such file or directory) — the recursive wildcard mishandles top-level files. lftp mirror doesn’t have this problem.
  • Raw Obsidian files break the schema (e.g. tags: a ; b ; c as a string, or a duplicated # H1). Clean them at build time instead of hand-fixing each file.
  • The content repo’s own README.md gets picked up as a “post” and fails validation — exclude README/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

Terminal window
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