Quick blog deployment: Astro, Caddy and a GitHub Actions pipeline


In the previous post we set up a clean, hardened VPS. Now let’s put something on it: this blog. This post is the short version of how it was made, from npm create astro to a git push that updates the live site.

What we will end up with:

  • a static blog built with Astro, with tags, RSS and a graph of how posts connect,
  • Caddy serving it over HTTPS on the VPS, certificates included,
  • a GitHub Actions pipeline that builds the site and deploys it on every push to main.

Step 1: The blog development

Astro choice

First things first, why Astro?

For a blog, what we want is simple: write posts in Markdown, get plain HTML files out. Astro does exactly that. npm run build produces a dist/ folder of HTML, CSS and a bit of JavaScript. There is no server-side code and no database, so there is nothing to patch or to crash at runtime: the server only has to hand out files.

It also comes with a blog template that already has the boring parts done (layout, RSS, sitemap, Markdown and MDX support), which is a great starting point.

Implementation

The CiPipes blog repository can be found here.

The major steps of the development were pretty straightforward:

  • Creating the Astro project, using:

    npm create astro@latest -- --template blog
    cd blog-frontend && npm run dev   # http://localhost:4321
  • Cleaning up the initial template by deleting placeholder posts, placeholder images and a local font.

  • Some configuration changes, notably in astro.config.mjs and package.json (site URL, font, adding astro check, etc.).

  • Reworking the existing components.

  • Some styling (global stylesheet, layouts).

  • Adding the graph view feature with its associated changes (component, content.config.ts frontmatter, and a TypeScript script to turn posts into nodes and edges). There is one node per post, one per tag, and an edge whenever a post has a tag, lists another post in related, or links to it in its body. GraphView.astro draws all that as an SVG with a small hand-written force simulation (repulsion, springs, centering). No charting library, no extra dependency. You can see it on /graph/.

  • Creating new components (PostList, SocialLinks, Logo).

  • Adding pages and assets to fill up the blog (home, tags, 404, etc.).

Now that we have the blog repo, how to serve it?

Step 2: Serve it with Caddy

Why Caddy and not Nginx

Nginx is a great web server, and it would do the job. But for this project we don’t need anything it is good at: no complex routing, no load balancing, no rewrite rules. We only need to serve a folder of files over HTTPS. For that, Caddy is simpler:

  • HTTPS is automatic. Caddy gets the Let’s Encrypt certificates and renews them on its own. With Nginx we would install certbot, set up the renewal timer, the reload hook and the port 80 → 443 redirect ourselves.
  • The config is short. The whole site fits in about 30 lines, including security headers, compression and caching. Fewer lines, fewer ways to get it wrong.
  • Good defaults. Modern TLS only, HTTP/2 and HTTP/3, no directory listing.

If one day we need fine-grained routing or heavy tuning, Nginx will be worth a look again. Today it would just be more configuration for the same result.

Install Caddy

From Caddy’s official apt repository:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy

Caddy is now installed as a systemd service running as its own caddy user.

Before going further, two things must be true:

  • the DNS A records of cipipes.com and www.cipipes.com point to the VPS’s IP (in our case managed in OVH, since the domain name was bought there),
  • the firewall allows 80/tcp and 443/tcp (sudo ufw allow 80/tcp && sudo ufw allow 443/tcp). Port 80 is needed for Let’s Encrypt to validate the domain.

The build folder

Once the blog is built, we need to store the result in a directory where Caddy can serve it. We will use /srv/www/cipipes.com/releases and change the ownership of the root folder to match the CI runner’s user:

sudo mkdir -p /srv/www/cipipes.com/releases
sudo chown -R 1001:1001 /srv/www/cipipes.com   # the CI runner's user, see Step 3
sudo chmod 755 /srv/www /srv/www/cipipes.com

Each deploy will land in its own folder under releases/, and a current symlink will point to the live one.

The Caddyfile

Let’s edit /etc/caddy/Caddyfile:

# Defining here security header for CORS, no iframe and no sniffing
(security_headers) {
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
}

# routing cipipes.com
cipipes.com {
	import security_headers
	# serving the folder /srv/www/cipipes.com/current
	root * /srv/www/cipipes.com/current
	encode zstd gzip

	# Astro's hashed assets never change: we cache them forever
	@hashed path /_astro/*
	header @hashed Cache-Control "public, max-age=31536000, immutable"

	file_server

	# Serve Astro's 404 page, with a real 404 status
	handle_errors {
		@notfound expression {err.status_code} == 404
		handle @notfound {
			rewrite * /404.html
			file_server
		}
	}
}

# redirecting www.cipipes.com to cipipes.com
www.cipipes.com {
	redir https://cipipes.com{uri} permanent
}

Then we can validate the file and reload Caddy:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo journalctl -u caddy -n 50 --no-pager   # we can look for "certificate obtained successfully"

Note

Until the first deploy creates current, the site returns a 404. That’s expected.

Step 3: Build and deploy with GitHub Actions

The idea

The simplest pipeline would build on GitHub and rsync the result over SSH. But then GitHub holds a private key that can write to our server. We would rather not.

Instead, we can set up a GitHub Actions runner container on our VPS, mounting /srv/www/cipipes.com to copy the blog build result and make it then accessible to Caddy at system level. (You can read Containerized GitHub Actions Runners - Build Your Own Self-Hosted Runner for more info on the topic).

The job sequence would look like this:

flowchart TD
    push(["git push to main"]) --> gh["GitHub Actions<br/>picks a runner labeled blog"]

    subgraph vps["OVH VPS"]
        subgraph runner["Runner container"]
            build["<b>1. Build</b><br/>npm ci<br/>npm run check<br/>npm run build"]
            deploy["<b>2. Deploy</b><br/>copy dist/ to releases/date-sha<br/>swap the current symlink"]
            smoke["<b>3. Smoke test</b><br/>version.txt == commit SHA?"]
            build --> deploy --> smoke
        end
        webroot[("/srv/www/cipipes.com<br/>current → releases/...")]
        caddy["Caddy<br/>HTTPS"]
        deploy -- "mounted volume" --> webroot
        caddy -- "serves current/" --> webroot
    end

    gh --> build
    smoke -. "GET https://cipipes.com/version.txt" .-> caddy
    visitor(["Visitors"]) --> caddy

The runner

The runner container gets two additions: the web root mounted inside it, and a label so the deploy job can only land on this runner. We can adapt the docker-compose.yaml of CiPipes/github_runner with:

# docker-compose.yaml (runner on the VPS)
environment:
  - RUNNER_LABELS=blog
volumes:
  # same path inside and outside, so the `current` symlink resolves the same way
  - /srv/www/cipipes.com:/srv/www/cipipes.com

Note

The runner’s user inside the container has UID 1001, which is why the web root belongs to 1001 on the host, as mentioned previously.

The workflow

Here is a simple and minimalistic workflow to build and deploy the blog:

# .github/workflows/deploy.yml
name: Deploy CiPipes Blog

on:
  push:
    branches: [main]
  workflow_dispatch: # To be able to trigger it manually or from another pipeline

concurrency: # one deploy at a time
  group: deploy-cipipes
  cancel-in-progress: false

jobs:
  build:
    runs-on: blog
    env:
      WEB_ROOT: /srv/www/cipipes.com
      SITE_URL: https://cipipes.com
      KEEP_RELEASES: 5
    steps:
      - name: Checkout repo
        uses: actions/checkout@v7
      - name: Checkout blog content
        uses: actions/checkout@v7
        with:
          repository: CiPipes/blog-content
          token: ${{ secrets.BLOG_CONTENT_TOKEN }} # token with only content read permission to blog-content repo
          path: src/content
          sparse-checkout: /blog/
          sparse-checkout-cone-mode: false
      - name: Setup Node
        uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - name: Install dependencies
        run: npm ci
      - name: Run Check
        run: npm run check
      - name: Build blog
        run: npm run build
      - name: Publish release
        run: |
          set -euo pipefail
          release="$(date -u +%Y%m%d-%H%M%S)-${GITHUB_SHA::7}"
          dest="$WEB_ROOT/releases/$release"
          cp -r "./dist" "$dest.tmp"
          echo "$GITHUB_SHA" > "$dest.tmp/version.txt"
          chmod -R u=rwX,go=rX "$dest.tmp"    # Caddy only needs to read
          mv "$dest.tmp" "$dest"
          ln -sfn "releases/$release" "$WEB_ROOT/current.tmp"
          mv -T "$WEB_ROOT/current.tmp" "$WEB_ROOT/current"
          echo "Live release: $release" >> "$GITHUB_STEP_SUMMARY"

      - name: Prune old releases
        working-directory: ${{ env.WEB_ROOT }}/releases
        run: |
          find . -mindepth 1 -maxdepth 1 -printf '%f\n' | sort -r | tail -n +$((KEEP_RELEASES + 1)) | xargs -r rm -rf --

      - name: Smoke test
        run: |
          for _ in 1 2 3 4 5; do
            live="$(curl -fsS "$SITE_URL/version.txt" || true)"
            if [ "$live" = "$GITHUB_SHA" ]; then
              echo "$SITE_URL serves $GITHUB_SHA"
              exit 0
            fi
            sleep 3
          done
          echo "::error::$SITE_URL/version.txt returned '${live}', expected $GITHUB_SHA"
          exit 1

A few details are worth explaining.

The code and the content live in two repositories. The blog’s code is public in blog-frontend, so anyone can see how it is built. The posts live in a private blog-content repository, where my notes and drafts can stay until they are ready to publish. The second checkout step bridges the two: it pulls only the blog/ folder of blog-content into src/content/ before Astro builds the site.

Deploys are atomic. If we copied the build straight into the served folder, a visitor could load a page in the middle of the copy and get a mix of old and new files. Instead, each release gets its own folder, and current is a symlink to the live one:

/srv/www/cipipes.com/
├── current -> releases/20260929-151203-cf78c05
└── releases/
    ├── 20260928-101544-6a7de61/
    └── 20260929-151203-cf78c05/

mv -T swaps the symlink in a single system call, so every request sees either the whole old site or the whole new one. Caddy doesn’t even need a reload, and rolling back is just pointing current at an older release.

A smoke test checks the live site. Each release contains a version.txt holding the commit SHA it was built from. The last step fetches https://cipipes.com/version.txt and fails unless it returns that SHA. This one request covers the files, the permissions, the symlink, Caddy, TLS and DNS, so a green pipeline means the site really is live.

Deploying when a post is pushed

The workflow above runs on every push to blog-frontend. But most of the time, what changes is a post in blog-content, and that push doesn’t trigger anything in the other repository. This is what the workflow_dispatch trigger is for: blog-content gets a small workflow of its own that asks GitHub to start the deploy whenever a post lands on main:

# blog-content: .github/workflows/trigger-deploy.yml
name: Trigger blog deploy

on:
  push:
    branches: [main]
    paths:
      - 'blog/**' # trigger only on change in that folder

jobs:
  trigger:
    runs-on: self-hosted # or any other runner
    steps:
      - name: Start the deploy workflow in blog-frontend
        env:
          GH_TOKEN: ${{ secrets.BLOG_FRONTEND_DISPATCH_TOKEN }}
        run: |
          gh workflow run deploy.yml --repo CiPipes/blog-frontend --ref main
          echo "Deploy requested for content commit ${GITHUB_SHA::7}" >> "$GITHUB_STEP_SUMMARY"

The built-in GITHUB_TOKEN only has rights on the repository the workflow runs in, so it can’t start a workflow in blog-frontend. BLOG_FRONTEND_DISPATCH_TOKEN is a fine-grained personal access token limited to CiPipes/blog-frontend, with the single permission Actions: Read and write. It can start workflows there, and nothing else.

No change is needed on the blog-frontend side: the dispatched run checks out the latest main of blog-content, and its concurrency group queues it behind any deploy that is already running.

Wrapping up

Publishing a post is now a plain git push to the content repository:

# in blog-content
git add blog/my-post.md
git commit -m "new post: my post"
git push    # triggers the deploy in blog-frontend, live about a minute later

To preview a draft before pushing, we can link the content folder into a local clone of blog-frontend and start the dev server:

# in blog-frontend, with blog-content cloned next to it
ln -s ../../../blog-content/blog src/content/blog
npm run dev    # http://localhost:4321

Here is the full picture, from a git push in either repository to a page in a visitor’s browser:

flowchart TD
    subgraph github["GitHub"]
        subgraph content["CiPipes/blog-content (private)"]
            cpush(["git push to main<br/>blog/** changed"]) --> trigger["<b>trigger-deploy.yml</b><br/>gh workflow run deploy.yml"]
        end
        subgraph frontend["CiPipes/blog-frontend (public)"]
            fpush(["git push to main"]) --> deployyml["<b>deploy.yml</b><br/>concurrency: deploy-cipipes<br/>one deploy at a time, others queue"]
        end
        trigger -- "workflow_dispatch<br/>BLOG_FRONTEND_DISPATCH_TOKEN" --> deployyml
    end

    subgraph vps["OVH VPS · ufw allows 22, 80, 443"]
        subgraph runner["Runner container · label blog · UID 1001"]
            checkout["<b>Checkout</b><br/>blog-frontend code<br/>+ blog-content /blog/ into src/content<br/>BLOG_CONTENT_TOKEN, read-only"]
            build["<b>Build</b><br/>Node 22 · npm ci<br/>npm run check · npm run build"]
            publish["<b>Publish release</b><br/>copy dist/ to releases/date-sha<br/>add version.txt<br/>swap current symlink with mv -T"]
            prune["<b>Prune</b><br/>keep the last 5 releases"]
            smoke["<b>Smoke test</b><br/>version.txt == commit SHA?<br/>up to 5 tries"]
            checkout --> build --> publish --> prune --> smoke
        end
        webroot[("/srv/www/cipipes.com<br/>owned by UID 1001<br/>current → releases/...")]
        caddy["<b>Caddy</b><br/>automatic HTTPS, Let's Encrypt<br/>security headers · zstd/gzip<br/>/_astro/* cached forever · 404 page<br/>www → cipipes.com redirect"]
        publish -- "mounted volume" --> webroot
        prune -- "rm old releases" --> webroot
        caddy -- "serves current/" --> webroot
    end

    deployyml -- "job for runs-on: blog<br/>runner connects out, no inbound SSH" --> checkout
    smoke -. "GET https://cipipes.com/version.txt" .-> caddy
    visitors(["Visitors"]) -- "DNS A records → VPS IP<br/>HTTPS" --> caddy

Let’s recap what we built:

  • A static Astro blog: Markdown in, HTML out, nothing to run on the server
  • Caddy serving it with automatic HTTPS, in about 30 lines of config
  • Public code and private content, kept in two repositories
  • A self-hosted runner on the VPS that builds and deploys: no inbound SSH, no deploy key held by GitHub
  • Atomic releases, one-command rollback, and a smoke test against the live site
  • A deploy on every push, whether it changes the code or a post

And yes, this post was deployed by pushing it.

Where this post fits

This post, its tags, and everything they connect to. Open the full graph →

posttag
cd ../blog