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.mjsandpackage.json(site URL, font, addingastro check, etc.). -
Reworking the existing components.
-
Some styling (global stylesheet, layouts).
-
Adding the graph view feature with its associated changes (component,
content.config.tsfrontmatter, 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 inrelated, or links to it in its body.GraphView.astrodraws 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
Arecords ofcipipes.comandwww.cipipes.compoint to the VPS’s IP (in our case managed in OVH, since the domain name was bought there), - the firewall allows
80/tcpand443/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 →
Nothing to map yet — tag a post, or link two posts together, to see them here.
No posts or tags match your search.