Blog web server at-home guide
Disclaimer: This post was written with the assistance of Claude Sonnet
This blog is hosted at my home using Astro site, with automatic deployment from GitHub and secure exposure via Cloudflare Tunnel. If you’re interested in setting up something similar, you can probably follow this guide with some success.
If you’re interested in how I’m running the Pi on solar power ☀️, I’ll share that setup in another post soon.
What you’ll end up with
- An Astro site served by Caddy on the Pi
- A systemd timer that polls
mainevery 2 minutes and rebuilds on changes - A Cloudflare Tunnel exposing the site at your custom domain over HTTPS
- No exposed ports on your home router — the Pi only makes outbound connections
Prerequisites (for my setup)
- A Raspberry Pi (or some cheap computer running Debian-based linux)
- Your Astro site in a GitHub repo
- A domain managed through Cloudflare (free plan is fine)
ghCLI installed and authenticated on the Pi- Node.js and pnpm installed (or npm — adjust commands accordingly)
Throughout this guide, replace yourdomain.com, youruser, and yourrepo with your actual values.
1. Clone your repo
Pick a location and clone:
mkdir -p ~/Projects
cd ~/Projects
gh repo clone yourusername/yourrepo blog
cd blog
pnpm install
pnpm build
Confirm the build produces a dist/ directory. You won’t be able to continue until a manual build works.
2. Install and configure Caddy
Caddy serves the static dist/ directory locally. The Cloudflare Tunnel will reach it on localhost:8080, so no public-facing TLS is needed at this layer — Cloudflare’s edge handles HTTPS for the public site.
Install Caddy:
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 caddy
Replace /etc/caddy/Caddyfile with:
:8080 {
root * /home/youruser/Projects/blog/dist
file_server
encode gzip
}
Fix permissions
Caddy runs as the caddy user and needs to traverse your home directory to reach dist/. Allow traversal without exposing directory contents:
chmod o+x /home/youruser /home/youruser/Projects /home/youruser/Projects/blog
chmod -R o+rX /home/youruser/Projects/blog/dist
Reload Caddy and verify:
sudo systemctl reload caddy
curl -I http://localhost:8080
You should get a 200 OK response.
3. Set up the deploy script
This script pulls from GitHub, rebuilds if there are new commits, and updates permissions for Caddy.
Create /home/youruser/Projects/blog/deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /home/youruser/Projects/blog
# Retry git fetch on transient DNS/network failures
for i in 1 2 3; do
if git fetch --quiet origin main; then
break
fi
echo "git fetch failed (attempt $i), retrying in 10s..."
sleep 10
done
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" = "$REMOTE" ]; then
exit 0
fi
git reset --hard origin/main
pnpm install --frozen-lockfile
pnpm build
chmod -R o+rX dist
Make it executable and test it:
chmod +x /home/youruser/Projects/blog/deploy.sh
/home/youruser/Projects/blog/deploy.sh
If you use npm instead of pnpm, swap those two lines for npm ci and npm run build.
Note on PATH for systemd
systemd services run with a minimal PATH. Verify your build tools are at standard locations:
which pnpm # or which npm
which node
If they’re under ~/.nvm/, ~/.local/share/pnpm/, or similar, either install Node from apt (sudo apt install nodejs npm) or use absolute paths in the script.
4. Set up the systemd timer
Two files: a service that runs the script, and a timer that triggers it every 2 minutes.
/etc/systemd/system/blog-deploy.service:
[Unit]
Description=Pull and rebuild blog
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=youruser
WorkingDirectory=/home/youruser/Projects/blog
ExecStart=/home/youruser/Projects/blog/deploy.sh
/etc/systemd/system/blog-deploy.timer:
[Unit]
Description=Run blog deploy every 2 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=2min
Unit=blog-deploy.service
[Install]
WantedBy=timers.target
The network-online.target dependency prevents the timer from running before DNS is ready on boot — without it, you’ll see Could not resolve host: github.com errors.
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now blog-deploy.timer
systemctl list-timers blog-deploy.timer
To check that pushes are being picked up:
journalctl -u blog-deploy -f
Push a trivial change to main and within 2 minutes you should see the deploy run.
5. Set up Cloudflare Tunnel
This exposes the Pi to the public internet without opening any router ports. Cloudflare’s edge handles HTTPS termination; cloudflared on the Pi makes an outbound connection to Cloudflare and tunnels traffic back.
Configure SSL mode
In the Cloudflare dashboard for your domain, go to SSL/TLS → Overview and set the mode to Full (strict).
Install cloudflared
Check your Pi’s architecture:
dpkg --print-architecture
For arm64 (most modern Pis on 64-bit OS):
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb
sudo dpkg -i cloudflared.deb
For armhf (32-bit OS), use cloudflared-linux-armhf.deb instead.
Verify:
cloudflared --version
Authenticate and create the tunnel
cloudflared tunnel login
This prints a URL — open it in any browser, log in, and authorize your domain. A cert is saved to ~/.cloudflared/cert.pem.
Create the tunnel:
cloudflared tunnel create blog
Note the tunnel UUID it prints. A credentials file is saved at ~/.cloudflared/<UUID>.json.
Route DNS to the tunnel
cloudflared tunnel route dns blog yourdomain.com
cloudflared tunnel route dns blog www.yourdomain.com
This adds proxied CNAME records in Cloudflare pointing both names at the tunnel.
Configure the tunnel
Because the systemd service runs cloudflared as root, the config needs to live in /etc/cloudflared/, not ~/.cloudflared/. Create it directly there:
sudo mkdir -p /etc/cloudflared
sudo cp ~/.cloudflared/<UUID>.json /etc/cloudflared/
sudo tee /etc/cloudflared/config.yml > /dev/null <<'EOF'
tunnel: <UUID>
credentials-file: /etc/cloudflared/<UUID>.json
ingress:
- hostname: yourdomain.com
service: http://localhost:8080
- hostname: www.yourdomain.com
service: http://localhost:8080
- service: http_status:404
EOF
Replace <UUID> with your actual tunnel UUID in both places. The trailing http_status:404 is a catch-all so unmatched hostnames don’t leak through.
Run cloudflared as a service
sudo cloudflared service install
sudo systemctl enable --now cloudflared
sudo journalctl -u cloudflared -f
You should see four connections register to Cloudflare’s edge within a few seconds.
Test from outside your network
From a phone on cell data, or any device elsewhere:
curl -I https://yourdomain.com
You should get a 200 response with a valid Cloudflare-issued cert.
Verifying the full loop
Push a trivial change to main, then watch both logs in separate terminals:
journalctl -u blog-deploy -f
sudo journalctl -u cloudflared -f
Within 2 minutes you’ll see the deploy script run, fetch the change, run pnpm build, and update permissions. The change is live on your domain immediately — Caddy serves files straight from disk, so there’s nothing to restart.
What this setup gets you
- No exposed router ports. Both inbound paths come from outbound connections the Pi initiates.
- Automatic HTTPS. Cloudflare provisions and renews certs at the edge.
- Atomic-ish deploys. Builds take a few seconds for a typical Astro site; visitors during a build get the previous version until the new files land in
dist/. For zero-downtime swaps, build to a staging directory and rename. - Git as source of truth. Never edit code on the Pi —
git reset --hardwill overwrite it. Push tomainand let the timer pick it up.