How to host a static blog on a free Google Cloud VM


This guide sets up a static Astro blog on Google Cloud’s free-tier VM, an e2-micro with 2 shared vCPUs and 1 GB of RAM. Caddy serves the site over HTTPS, and GitLab CI builds and deploys it on every push to main.

This whole setup, including this site, was built in one afternoon session with Claude. Claude wrote the scripts and config files, walked through each step, and read the CI logs to debug the failures listed under Troubleshooting. The last section covers how that worked.

Replace these placeholders with your own values throughout:

Placeholder Meaning
<DOMAIN> a domain you own, e.g. example.com
<SITE_DOMAIN> the subdomain the blog lives on, e.g. blog.example.com
<VM_IP> the VM’s static external IP
<VM_NAME>, <ZONE> the GCE instance name and zone
<GITLAB_PATH> your GitLab namespace and project, e.g. you/blog

Before you start

You need:

  • A GCE e2-micro VM running Debian, with “Allow HTTP traffic” and “Allow HTTPS traffic” ticked.
  • A domain whose DNS you can edit.
  • A gitlab.com account.
  • Node.js LTS on your own machine (from nodejs.org).

To stay inside the free tier, the VM has to be in us-west1, us-central1 or us-east1, and its boot disk has to be Standard persistent disk, not Balanced or SSD.

Why a static site

A 1 GB VM can run a Node server, but a mostly-text blog doesn’t need one. Astro builds the whole site to plain HTML, and Caddy serves it and handles HTTPS certificates on its own, so Caddy is the only thing running on the server. If you later need server routes, you can add Astro’s Node adapter.

The free tier’s tightest limit is egress, about 1 GB a month out of North America, not CPU or RAM. A text blog with light traffic stays well under that. An image-heavy site would not, so for that case put a CDN in front or host the images elsewhere.

1. Set up a static IP and DNS

In the GCP console, go to VPC network → IP addresses and promote the VM’s ephemeral external IP to static. An ephemeral IP can change when the VM stops, which would break your DNS record.

Next, at your DNS provider, add an A record for the subdomain pointing at <VM_IP>, with a TTL of 300. Then confirm it resolves from the VM:

getent hosts <SITE_DOMAIN>

It should print <VM_IP>. Don’t continue until it does, because Caddy requests a certificate as soon as it starts.

2. Create a deploy key

On your own machine, create a key that’s only for CI deploys:

ssh-keygen -t ed25519 -f gitlab-deploy -C gitlab-deploy

Press Enter at the passphrase prompt to leave it empty, since CI can’t type one. The public half, gitlab-deploy.pub, goes on the VM. The private half, gitlab-deploy, goes into GitLab. Don’t commit either file.

3. Configure the server

Save this as setup-vm.sh, and change the SITE line to your subdomain. The script adds a little swap, installs Caddy from its official apt repo, and creates a deploy user whose key can only run rrsync into the web root. If the CI key ever leaks, it can write the site’s files and nothing else.

#!/usr/bin/env bash
# Usage: sudo bash setup-vm.sh "<contents of gitlab-deploy.pub>"
set -euo pipefail

SITE="<SITE_DOMAIN>"
WEBROOT=/var/www/$SITE
DEPLOY_USER=deploy
PUBKEY="${1:?usage: sudo bash setup-vm.sh '<deploy public key>'}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

# Swap: a safety margin for apt upgrades on 1 GB of RAM
if ! swapon --show=NAME --noheadings | grep -qx /swapfile; then
  fallocate -l 1G /swapfile && chmod 600 /swapfile
  mkswap /swapfile && swapon /swapfile
  echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf
sysctl -q -p /etc/sysctl.d/99-swappiness.conf

export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get install -y -q debian-keyring debian-archive-keyring apt-transport-https \
  curl gnupg rsync python3 unattended-upgrades

# Caddy from its official repo
if [[ ! -f /usr/share/keyrings/caddy-stable-archive-keyring.gpg ]]; then
  curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
    | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
  curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
    > /etc/apt/sources.list.d/caddy-stable.list
  apt-get update -q
fi
apt-get install -y -q caddy

# Deploy user, locked to rsync inside the web root
id "$DEPLOY_USER" &>/dev/null || useradd --create-home --shell /bin/sh "$DEPLOY_USER"
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh"
echo "command=\"$(command -v rrsync) $WEBROOT\",restrict $PUBKEY" \
  > "/home/$DEPLOY_USER/.ssh/authorized_keys"
chown "$DEPLOY_USER:$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh/authorized_keys"
chmod 600 "/home/$DEPLOY_USER/.ssh/authorized_keys"

install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$WEBROOT"

install -m 644 "$SCRIPT_DIR/Caddyfile" /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
systemctl enable --now caddy
systemctl reload caddy

# Line for GitLab's SSH_KNOWN_HOSTS variable
EXTERNAL_IP="$(curl -sf -H 'Metadata-Flavor: Google' \
  'http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip')"
echo "$SITE,$EXTERNAL_IP $(cut -d' ' -f1,2 /etc/ssh/ssh_host_ed25519_key.pub)"

Save this as Caddyfile in the same folder, again with your subdomain:

<SITE_DOMAIN> {
	root * /var/www/<SITE_DOMAIN>
	encode zstd gzip
	file_server

	# Astro's hashed assets never change, so cache them for a year
	@hashed path /_astro/*
	header @hashed Cache-Control "public, max-age=31536000, immutable"

	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	handle_errors {
		@404 expression {err.status_code} == 404
		handle @404 {
			rewrite * /404.html
			file_server
		}
	}
}

Copy the script, the Caddyfile and the public key to the VM together. The script stops if the Caddyfile isn’t next to it. Then run it:

gcloud compute scp setup-vm.sh Caddyfile gitlab-deploy.pub <VM_NAME>:~ --zone <ZONE>
gcloud compute ssh <VM_NAME> --zone <ZONE>
sudo bash setup-vm.sh "$(cat gitlab-deploy.pub)"

Make sure you’re on the VM when you run the last command. If the prompt shows your own machine’s name, sudo asks for your local password and the script won’t work.

The script ends by printing a line that starts with <SITE_DOMAIN>,<VM_IP> ssh-ed25519. Save it for step 5. At this point https://<SITE_DOMAIN> should load with a valid certificate. If it doesn’t, check journalctl -u caddy -n 50 on the VM.

4. Create the Astro project

On your own machine:

npm create astro@latest blog -- --template blog
cd blog
npx astro add react

Answer yes to installing dependencies, initializing git, and the changes astro add proposes. Then:

  1. In astro.config.mjs, set site: 'https://<SITE_DOMAIN>'. The sitemap, RSS feed and canonical URLs use it.
  2. In src/consts.ts, set SITE_TITLE and SITE_DESCRIPTION.
  3. Run npm run build and check that dist/ contains an index.html.

Commit the package-lock.json this creates, because CI installs with npm ci.

5. Add the GitLab pipeline

Save this as .gitlab-ci.yml in the project root. Every push to main builds the site and rsyncs dist/ to the VM. Merge requests only build.

stages:
  - build
  - deploy

variables:
  npm_config_cache: "$CI_PROJECT_DIR/.npm"

build:
  stage: build
  image: node:24-alpine
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - .npm/
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

deploy:
  stage: deploy
  image: alpine:3
  needs:
    - build
  resource_group: production
  environment:
    name: production
    url: https://<SITE_DOMAIN>
  before_script:
    - apk add --no-cache openssh-client rsync
    - install -d -m 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - chmod 600 "$DEPLOY_SSH_KEY"
  script:
    - >
      rsync -rlptz --delete --chmod=D755,F644
      -e "ssh -i $DEPLOY_SSH_KEY -o StrictHostKeyChecking=yes"
      dist/ "deploy@$DEPLOY_HOST:./"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Create a blank project on gitlab.com, and untick “Initialize repository with a README”. Before pushing anything, go to the project’s Settings → CI/CD → Variables and add these three. Add them at the project level, not the group level, so no other project gets the key:

Key Type Value
DEPLOY_HOST Variable <SITE_DOMAIN>
SSH_KNOWN_HOSTS Variable the line the setup script printed
DEPLOY_SSH_KEY File the full contents of gitlab-deploy, plus a trailing newline

For each one, tick “Protect variable” and set Visibility to “Visible”. GitLab can’t mask values that contain spaces or line breaks, and masking isn’t what protects them anyway. Protection and a private project do that.

DEPLOY_SSH_KEY must be type File. As a File variable, $DEPLOY_SSH_KEY is the path to a temporary file holding the key. As a plain Variable, it holds the key text itself, and the deploy step’s chmod will print the whole private key into the job log.

On macOS, pbcopy < gitlab-deploy copies the key so you can paste it.

Then push:

git remote add origin git@gitlab.com:<GITLAB_PATH>.git
git push -u origin main

Watch Build → Pipelines. When both jobs are green, https://<SITE_DOMAIN> serves the blog.

Troubleshooting

Permission denied (publickey) when pushing to GitLab. Your account has no SSH key from this machine. Add one under Edit profile → SSH Keys, using a separate key from the deploy key, or switch the remote to HTTPS and use a personal access token.

The pipeline fails immediately with zero jobs. New gitlab.com accounts have to verify their identity before they can use the shared runners. Complete the verification, then start a new run from Build → Pipelines → Run pipeline.

The private key shows up in the job log. DEPLOY_SSH_KEY was saved as a Variable instead of a File. Treat the key as exposed:

  1. Erase the job log (the trash icon on the job page).
  2. Generate a new key with the command from step 2.
  3. Rerun the setup script on the VM with the new public key.
  4. Recreate DEPLOY_SSH_KEY as a File variable with the new private key.

The deploy fails with Permission denied (publickey). Test the key from your own machine:

ssh -i gitlab-deploy deploy@<SITE_DOMAIN>
  • If you get rrsync error: Not invoked via sshd, the key works on the VM, because rrsync refuses interactive logins. The value in GitLab is wrong: re-paste the private key and retry the deploy job.
  • If you get Permission denied, the VM has a different key. Check /home/deploy/.ssh/authorized_keys on the VM and rerun the setup script with the right public key.

Publishing posts

Add Markdown or MDX files to src/content/blog/, then commit and push. The pipeline takes about half a minute, and the post is live once it finishes.

How this was built with Claude

Everything above came out of a single conversation with Claude in the Claude app, starting from the question “what are my options for hosting a website on a 2 vCPU, 1 GB GCE VM?”

Choosing the stack. Claude laid out the options (static files, a small app server, a CMS, container platforms) and what fits in 1 GB of RAM. Once it was clear the VM was on the free tier, it pointed out that the 1 GB egress allowance, not memory, was the real limit. That changed the plan more than once: an early idea for a photo site would have used up the allowance quickly, and the DNS being on Route 53 ruled out the easy Cloudflare fixes. The result was a mostly-text blog, so it went fully static. Nuxt, Next.js, SvelteKit and Astro were compared, and Astro with React components won for the size of its UI ecosystem combined with plain-HTML output.

Writing the files. Claude wrote the VM setup script, the Caddyfile, the GitLab CI pipeline and a README. Its sandbox couldn’t reach the npm registry, so it couldn’t run the Astro scaffold or test a build itself. It said so, and had the project created locally with the official template rather than hand-writing one against an Astro version it couldn’t verify.

Walking through it. Claude went one step at a time, asking for command output before moving on. It caught small mistakes along the way, such as the setup script being run on the Mac instead of the VM, and a Caddyfile that hadn’t been copied over.

Debugging the pipeline. With the GitLab connector enabled, Claude could list pipelines and read job logs directly instead of asking for them to be pasted. That’s how it found the identity verification problem, spotted the private key printed in a log (and had it rotated), and narrowed the final permission error down to the value stored in GitLab.

Editing the site. After the deploy worked, Claude made changes like the site title, this post, and the layout tweaks by committing straight to main through the connector, then checking the pipeline result and the live page.

If you try the same approach, a few things helped:

  • Connect your Git host, so Claude can read CI logs and make commits instead of relaying everything through copy and paste.
  • Ask it to flag what it can’t verify. It said up front which parts were untested, which made it clear where to check carefully.
  • Paste the actual output at each step. Most of the fixes came from reading the exact error, not from guessing.