Notes
RSSCV
3 min readcaddydeployastro

Putting a blog under /blog without the CV deploy deleting it

rsync --delete-after removes anything not present in the source. If the blog and the CV share one web root, every CV deploy silently deletes the blog.

Diagram: one domain, two web roots, joined at the Caddy layer

My apex domain serves a static CV. I wanted to add a blog at bimosyahputro.com/blog — a subdirectory, not a subdomain, so Google counts it as one site and the authority doesn’t get split.

The most obvious approach looks correct: just put the blog’s build output inside the web root that already exists.

/var/www/bimosyahputro.com/
  index.html          ← CV
  blog/               ← blog

This works. Once. Then it vanishes on the next CV deploy.

Why it vanishes

The CV deploy script contains one unsuspicious line:

rsync -avz --delete-after "$CV_DIR/dist/" "$REMOTE:/var/www/bimosyahputro.com/"

--delete-after means: after copying, delete anything at the destination that does not exist in the source. cv/dist/ has no blog/ folder, so blog/ isn’t a file that happens to be left over — it is a file that must be deleted for the destination to match the source. rsync does exactly what it was asked to do.

What makes this dangerous isn’t the deletion, it’s the way it fails:

  • No error. The exit code stays 0.
  • The CV deploy still succeeds. What breaks is the part you weren’t looking at.
  • You find out when someone shares a link to your article and gets a 404.

The most expensive failures aren’t the loud ones — they’re the ones that wait until you stop paying attention.

The patch that comes to mind first is --exclude=blog. That moves the system’s correctness into a flag in one file, which every person and every future script touching that directory has to remember. A rule that only lives in people’s heads is not a rule.

Separate the directory ownership

The real problem isn’t the rsync flag — it’s two deploy processes writing to the same directory. The fix: give each one its own web root, and join them at the HTTP layer.

Two separate web roots, joined by Caddy

bimosyahputro.com {
	encode gzip zstd

	redir /blog /blog/ permanent

	handle_path /blog/* {
		root * /var/www/blog
		try_files {path} {path}.html
		file_server
	}

	handle {
		root * /var/www/bimosyahputro.com
		file_server
	}
}

handle_path strips the /blog prefix before looking up the file, so /blog/my-article is served as /var/www/blog/my-article.html. The final handle block catches everything else and serves the CV as before.

Now there is no way for the two deploy scripts to delete each other’s work, because they never touch the same directory:

Deploy Writes to Never touches
deploy.sh /var/www/bimosyahputro.com /var/www/blog
deploy-blog.sh /var/www/blog /var/www/bimosyahputro.com

What I like about this shape: the safety isn’t the result of being careful. There is nothing to remember. Even if I write a careless third script tomorrow, it still can’t break the others, because the boundary lives in the directory structure, not in a flag.

One more thing about base

Because the blog is served from /blog, Astro has to know that:

export default defineConfig({
  site: "https://bimosyahputro.com",
  base: "/blog",
  build: { format: "file" },
});

base prefixes every asset URL with /blog. Without it, the browser requests /_astro/index.js, the request falls through to the final handle block, and the CV answers — with a 404.

site looks optional but isn’t. It’s what makes the canonical URL, the sitemap, and og:image come out as absolute URLs. WhatsApp’s and LinkedIn’s scrapers don’t resolve relative paths and don’t run JavaScript. If og:image comes out as /blog/og/article.png, the link renders as plain text — again with no error anywhere.

← All posts