2896 words
14 minutes
Hosting Astro Blog on Tiny VPS with GitHub Actions, Nginx, and Cloudflare Tunnel

I recently came across a VPS deal that was hard to ignore: $2 per year.

The trade-offs are about what you would expect at this price: only two ports on a shared IPv4 address, no VNC access, just 256 MiB of RAM, and 1 GiB of disk space. Even so, it is still enough for a small static site.

So I decided to use it for an Astro blog, using my favorite theme: Fuwari. The site is built with GitHub Actions and then published through Cloudflare Tunnel.

Why not GitHub Pages? Because this blog lives in a private repository, and GitHub Pages for private repositories is not available on the free plan.

Architecture#

  • Build with GitHub Actions
  • Deploy generated files to /var/www/fuwari-blog via rsync
  • Serve static files with Nginx on 127.0.0.1:8080
  • Expose the service through cloudflared and map your own domain in Cloudflare Dashboard

Why this setup#

With only two ports available, one is already reserved for SSH, which leaves just one for everything else. I do not want to spend that last port on a Cloudflare setup, since I may need it later for debugging or some other service. So I decided to use Cloudflare Tunnel instead.

And with only 256 MiB of memory, building an Astro site directly on the VPS is not very practical. So I build the site in GitHub Actions and deploy only the generated files to the server with rsync over SSH.

System check#

The VPS comes with Debian 11 installed. After logging in, the first thing to check is what kind of virtualization it is running on:

root@localhost:~# systemd-detect-virt
lxc

Then check how much memory is available:

root@localhost:~# free -h
total used free shared buff/cache availables
Mem: 256Mi 16Mi 108Mi 0.0Ki 131Mi 239Mi
Swap: 256Mi 0.0Ki 255Mi

Next, check the disk space:

root@localhost:~# df -h
Filesystem Size Used Avail Use% Mounted on
/dev/vfg 975M 459M 465M 50% /
none 492K 4.0K 488K 1% /dev
devtmpfs 1.9G 0 1.9G 0% /dev/net
tmpfs 2.0G 0 2.0G 0% /dev/shm
tmpfs 785M 76K 785M 1% /run
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 393M 0 393M 0% /run/user/0

This VPS is running as an LXC guest with very limited memory and disk space, so the setup in this article is designed around that constraint.

WARNING

This VPS runs on LXC, which means the host node still controls the kernel.
In practice, the provider’s host system may be able to inspect container processes, filesystem contents, and network activity. Because of that, you should be careful about what you place on this VPS. I will come back to this later in the article.

Set up rsync target on VPS#

The deployed files will be synced to the VPS over rsync, so start by installing it on the server. Then create a dedicated deploy user and prepare the target directory.

a. Install rsync

Terminal window
apt install rsync

b. Create a dedicated deploy user

Terminal window
sudo adduser --disabled-password --gecos "" deploy

c. Prepare the target directory

Terminal window
sudo mkdir -p /var/www/fuwari-blog
sudo chown -R deploy:deploy /var/www/fuwari-blog

d. Switch to deploy user and generate an SSH key pair for deployment

Terminal window
sudo -iu deploy
Terminal window
ssh-keygen -t ed25519

The key pair will be stored in the default location:

~/.ssh/id_ed25519
~/.ssh/id_ed25519.pub

e. Allow this key to log in over SSH

Terminal window
cp ~/.ssh/id_ed25519.pub ~/.ssh/authorized_keys

CI/CD setup#

To automate the build and deployment process, the next step is to prepare the required secrets and workflow in GitHub Actions.

Add secrets to GitHub Actions#

Before creating the workflow, follow the official documentation and add the deployment credentials below as repository secrets or environment secrets in GitHub Actions:

  • VPS_HOST: VPS IP address, for example 123.45.67.89
  • VPS_PORT: SSH port, for example 22
  • VPS_USER: deployment user, for example deploy
  • VPS_TARGET_DIR: target directory on VPS, for example /var/www/fuwari-blog
  • VPS_SSH_KEY: private SSH key used for deployment

Set up GitHub Actions#

Create .github/workflows/deploy.yml with the following workflow:

deploy.yml
name: Build and Deploy to VPS
on:
workflow_dispatch:
push:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
17 collapsed lines
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build site
run: pnpm run build
- name: Upload build artifact for VPS deploy
uses: actions/upload-artifact@v7
with:
name: generated-site
path: dist
if-no-files-found: error
deploy:
needs: build
runs-on: ubuntu-latest
# Add below if you add secret as environment secrets instead of repository secrets
# environment: <secret_environment>
steps:
- name: Download runtime artifact
uses: actions/download-artifact@v8
with:
name: generated-site
path: ./deploy
26 collapsed lines
- name: Check website size (150 MB capacity guard)
run: |
SIZE_MB=$(du -sm ./deploy | cut -f1)
echo "Deploy folder size: ${SIZE_MB} MB"
if [ "$SIZE_MB" -gt 150 ]; then
echo "Artifact too large for this VPS capacity limit."
exit 1
fi
- name: Validate deploy secrets
run: |
test -n "${{ secrets.VPS_HOST }}" || (echo "Missing VPS_HOST" && exit 1)
test -n "${{ secrets.VPS_PORT }}" || (echo "Missing VPS_PORT" && exit 1)
test -n "${{ secrets.VPS_USER }}" || (echo "Missing VPS_USER" && exit 1)
test -n "${{ secrets.VPS_TARGET_DIR }}" || (echo "Missing VPS_TARGET_DIR" && exit 1)
test -n "${{ secrets.VPS_SSH_KEY }}" || (echo "Missing VPS_SSH_KEY" && exit 1)
- name: Configure SSH
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf "%s\n" "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "${{ secrets.VPS_PORT }}" "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts
- name: Rsync to VPS
run: |
rsync -a \
--delete-before \
--inplace \
--whole-file \
--no-compress \
--no-perms --no-owner --no-group \
-e "ssh -p ${{ secrets.VPS_PORT }} -i ~/.ssh/id_ed25519" \
./deploy/ \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:${{ secrets.VPS_TARGET_DIR }}/"
- name: Cleanup SSH key and known_hosts
if: always()
run: |
rm -f ~/.ssh/id_ed25519
rm -f ~/.ssh/known_hosts

This workflow is split into two jobs:

  • build installs dependencies and generates the static site in dist
  • The generated site is uploaded as a workflow artifact
  • deploy downloads the artifact and syncs it to the VPS
  • A size check helps prevent oversized deployments on a very small server
  • SSH key and known_hosts file are cleaned up after deployment

Web server setup#

Since the generated files will be served locally on the VPS, the next step is to install and configure Nginx.

Install Nginx#

Since the site will be served locally and exposed through Cloudflare Tunnel, the next step is to install Nginx. Here I am using the official Debian package repository provided by Nginx:

Terminal window
apt-get install curl gnupg2 ca-certificates lsb-release debian-archive-keyring
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
| sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
https://nginx.org/packages/debian `lsb_release -cs` nginx" \
| sudo tee /etc/apt/sources.list.d/nginx.list
apt-get update && apt-get install nginx

Configure Nginx#

After installing Nginx, remove the default config, add a new server block for the blog, then test and reload the service.

a. Remove default config and create new one for the blog

Terminal window
rm /etc/nginx/conf.d/default.conf
nano /etc/nginx/conf.d/fuwari-blog.conf

b. Use following configuration to serve the blog on 127.0.0.1:8080

fuwari-blog.conf
server {
listen 127.0.0.1:8080;
server_name localhost;
root /var/www/fuwari-blog;
gzip on;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|mjs|css|png|jpg|jpeg|gif|webp|svg|ico|woff2?)$ {
add_header Cache-Control "public, max-age=3600, immutable" always;
try_files $uri =404;
}
}

c. Test configuration and start Nginx

Terminal window
nginx -t
systemctl enable nginx --now

If Nginx is already running before any change is made, reload it to apply new configuration:

Terminal window
systemctl reload nginx

Prepare Cloudflare Tunnel#

With Nginx serving the site locally, the next step is to install Cloudflare Tunnel so the blog can be exposed without opening another public port.

Install cloudflared#

Next, install the Cloudflare Tunnel client, cloudflared, from Cloudflare’s Debian package repository:

Terminal window
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install cloudflared

Quick tunnel test#

Before setting up a tunnel in production, start with a quick tunnel to make sure the local Nginx service is reachable through Cloudflare Tunnel:

Terminal window
cloudflared tunnel --url http://localhost:8080
2026-04-14T03:13:00Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee, are subject to the Cloudflare Online Services Terms of Use (https://www.cloudflare.com/website-terms/), and Cloudflare reserves the right to investigate your use of Tunnels for violations of such terms. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
2026-04-14T03:13:00Z INF Requesting new quick Tunnel on trycloudflare.com...
2026-04-14T03:13:09Z INF +--------------------------------------------------------------------------------------------+
2026-04-14T03:13:09Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
2026-04-14T03:13:09Z INF | https://<random-string>.trycloudflare.com |
2026-04-14T03:13:09Z INF +--------------------------------------------------------------------------------------------+
2026-04-14T03:13:09Z INF Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ~/.cloudflare-warp ~/cloudflare-warp /etc/cloudflared /usr/local/etc/cloudflared]
2026-04-14T03:13:09Z INF Version 2026.3.0-2-ga0e55fc9 (Checksum 81a043e9865c932b7755f347fdc404e3f246cb99b3251d6df022418b1b73b208)
2026-04-14T03:13:09Z INF GOOS: linux, GOVersion: go1.26.0, GoArch: amd64
2026-04-14T03:13:09Z INF Settings: map[ha-connections:1 protocol:quic url:http://localhost:8080]
2026-04-14T03:13:09Z INF cloudflared will not automatically update if installed by a package manager.
...

Open the https://<random-string>.trycloudflare.com URL from the log and check whether the blog is reachable.

Use Cloudflare Tunnel with own domain#

After validating the quick tunnel, create a production tunnel and publish the blog under your own domain.

The problem with the official approach#

CAUTION

The result below comes from following the official steps directly. On an LXC VPS, this is the approach you should avoid.

You may already have tried the official “Create a tunnel” documentation, but this approach has a problem here.

The official documentation asks you to execute the following command:

Terminal window
sudo cloudflared service install abcdefghijklmnopqrstuvwxyz

This command creates a systemd service that starts automatically at boot:

cloudflared.service
# /etc/systemd/system/cloudflared.service
[Unit]
Description=cloudflared
After=network-online.target
Wants=network-online.target
[Service]
TimeoutStartSec=15
Type=notify
ExecStart=/usr/bin/cloudflared --no-autoupdate tunnel run --token abcdefghijklmnopqrstuvwxyz
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target

When you inspect the process information, the token is embedded in the command line:

Terminal window
ps -fp "$(systemctl show -p MainPID --value cloudflared)"
UID PID PPID C STIME TTY TIME CMD
root 7668 1 0 06:57 ? 00:00:00 /usr/bin/cloudflared --no-autoupdate tunnel run --token abcdefghijklmnopqrstuvwxyz

cloudflared service install <token> works, but it embeds the token directly into the service startup command. On an LXC VPS, where the host system may be able to inspect container processes, filesystem contents, and network activity, that is not a risk I want to take.

Why passing credentials on the command line is risky#

Technically, unless you control your own encryption keys, VPS provider can potentially access your data regardless of whether the VPS runs on KVM or LXC. The difference is mostly in how direct that access is.

That is why the goal here is simply to avoid exposing the token directly in the process list, where it could be revealed by something as simple as ps -ef. If we are being serious about security, nothing on the filesystem should be treated as completely secret. What we can do here is avoid the most obvious and easily exposed method.

If qemu-ga is installed inside the VPS, the host can also use the guest agent to perform operations such as reading or writing files inside the guest. That may sound alarming, but qemu-ga is commonly used for guest management and automation, such as reporting system information, handling graceful shutdown or reboot, freezing filesystems for snapshots, and in some cases resetting a user password. On Azure, qemu-ga is not used; instead, Azure uses its own VM agent, waagent, which serves a similar but more Azure-specific purpose.

In short, credentials passed through command-line arguments are easier to expose, especially during troubleshooting or host-side inspection.

Set up the legacy Cloudflare tunnel#

The legacy Cloudflare tunnel, also known as a locally-managed tunnel, uses a file-based credential, so it does not appear directly in the process command line.

NOTE

Considered that the documentation now calls this a legacy configuration. Later, I will migrate it to the remotely-managed tunnel that Cloudflare recommends today.
One thing to keep in mind is that the old file-based credential remains valid and is [not revoked automatically][tunnel-permissions]. To revoke the credential, you need to delete the tunnel.

a. Log into cloudflared

Terminal window
cloudflared tunnel login

b. Follow the prompt to complete the login in your browser
An example prompt looks like this:

Please open the following URL and log in with your Cloudflare account:
https://dash.cloudflare.com/argotunnel?aud=&callback=https%3A%2F%2Flogin.cloudflareaccess.org%2Fxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Leave cloudflared running to download the cert automatically.
2026-04-14T04:14:38Z INF You have successfully logged in.
If you wish to copy your credentials to a server, they have been saved to:
/root/.cloudflared/cert.pem

c. Create the tunnel

Terminal window
cloudflared tunnel create fuwari-blog

The output will also show where the tunnel credentials are stored:

Tunnel credentials written to /root/.cloudflared/<TUNNEL-UUID>.json. cloudflared chose this file based on where your origin certificate was found. Keep this file secret. To revoke these credentials, delete the tunnel.
Created tunnel fuwari-blog with id <TUNNEL_UUID>

If you list the directory, you will see both the account certificate and the tunnel credential file:

Terminal window
ls ~/.cloudflared/
<TUNNEL-UUID>.json cert.pem

d. Run the tunnel in the foreground to verify the connection

Terminal window
cloudflared --no-autoupdate tunnel run <TUNNEL_UUID>

Output:

2026-04-14T05:17:24Z INF Starting tunnel tunnelID=<TUNNEL_UUID>
2026-04-14T05:17:24Z INF Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ~/.cloudflare-warp ~/cloudflare-warp /etc/cloudflared /usr/local/etc/cloudflared]
2026-04-14T05:17:24Z INF Version 2026.3.0-2-ga0e55fc9 (Checksum 81a043e9865c932b7755f347fdc404e3f246cb99b3251d6df022418b1b73b208)
2026-04-14T05:17:24Z INF GOOS: linux, GOVersion: go1.26.0, GoArch: amd64
2026-04-14T05:17:24Z INF Settings: map[no-autoupdate:true]
2026-04-14T05:17:24Z INF cloudflared will not automatically update if installed by a package manager.
2026-04-14T05:17:24Z INF Generated Connector ID: de376c93-29e6-4b83-a495-b3173274aa9c
2026-04-14T05:17:24Z WRN No ingress rules were defined in provided config (if any) nor from the cli, cloudflared will return 503 for all incoming HTTP requests
2026-04-14T05:17:24Z INF Initial protocol quic
2026-04-14T05:17:24Z INF ICMP proxy will use 10.0.1.4 as source for IPv4
2026-04-14T05:17:24Z INF ICMP proxy will use 2001:157:a:135::56b2 in zone eth0 as source for IPv6
2026-04-14T05:17:24Z WRN The user running cloudflared process has a GID (group ID) that is not within ping_group_range. You might need to add that user to a group within that range, or instead update the range to encompass a group the user is already in by modifying /proc/sys/net/ipv4/ping_group_range. Otherwise cloudflared will not be able to ping this network error="Group ID 0 is not between ping group 1 to 0"
2026-04-14T05:17:24Z WRN ICMP proxy feature is disabled error="cannot create ICMPv4 proxy: Group ID 0 is not between ping group 1 to 0 nor ICMPv6 proxy: socket: permission denied"
2026-04-14T05:17:24Z INF ICMP proxy will use 10.0.1.4 as source for IPv4
2026-04-14T05:17:24Z INF ICMP proxy will use 2001:157:a:135::56b2 in zone eth0 as source for IPv6
2026-04-14T05:17:24Z INF Starting metrics server on 127.0.0.1:20241/metrics
2026-04-14T05:17:24Z INF Tunnel connection curve preferences: [X25519MLKEM768 CurveP256] connIndex=0 event=0 ip=198.41.199.193
2026-04-14T05:17:24Z INF Registered tunnel connection connIndex=0 connection=e8669842-7a21-430d-8f26-0c828c73de3b event=0 ip=198.41.199.193 location=hkg01 protocol=quic
2026-04-14T05:17:24Z INF Tunnel connection curve preferences: [X25519MLKEM768 CurveP256] connIndex=1 event=0 ip=198.41.192.51
2026-04-14T05:17:24Z INF Registered tunnel connection connIndex=1 connection=49703548-fa99-4e3c-ba2e-3717550e383b event=0 ip=198.41.192.51 location=hkg11 protocol=quic
2026-04-14T05:17:25Z INF Tunnel connection curve preferences: [X25519MLKEM768 CurveP256] connIndex=2 event=0 ip=198.41.192.124
2026-04-14T05:17:25Z INF Registered tunnel connection connIndex=2 connection=e7c3020e-bab5-499b-a664-1460e73cfd12 event=0 ip=198.41.192.124 location=hkg01 protocol=quic
2026-04-14T05:17:26Z INF Tunnel connection curve preferences: [X25519MLKEM768 CurveP256] connIndex=3 event=0 ip=198.41.199.62
2026-04-14T05:17:26Z INF Registered tunnel connection connIndex=3 connection=d5c4628e-0005-42d4-9a4d-3d298c4b035d event=0 ip=198.41.199.62 location=hkg05 protocol=quic

Migrate from the legacy Cloudflare tunnel#

a. Move the tunnel credential into /etc/cloudflared and remove account certificate file

Terminal window
mkdir /etc/cloudflared/
mv ~/.cloudflared/*.json /etc/cloudflared/
rm ~/.cloudflared/cert.pem
NOTE

Also remember to delete API tokens created via cloudflared: https://dash.cloudflare.com/profile/api-tokens

b. Migrate legacy tunnel in Cloudflare Dashboard

After creating the tunnel locally, open the Cloudflare Zero Trust Dashboard and go to Networks -> Connectors -> Cloudflare Tunnels. You should see your tunnel listed there. Select the tunnel you created.

Since it is a locally managed tunnel, Cloudflare will show a migration prompt and tell you that it cannot be managed from the dashboard yet. Click Start migration to continue.

TIP

If the Start migration button is grayed out, it usually means either the previous cloudflared --no-autoupdate tunnel run <TUNNEL_UUID> command has not been run yet, or the server cannot reach Cloudflare. Run the command first, and if the button is still unavailable, check whether your network environment meets Cloudflare Tunnel’s outbound requirements.

Publish the blog#

Once the migration is complete, add a published application route for the blog:

  • Subdomain: blog (or any subdomain you prefer)
  • Domain: your domain
  • Type: HTTP
  • URL: localhost:8080

Then click Save. After that, Cloudflare will automatically configure the DNS record, and requests to your domain will be routed to the local Nginx service through tunnel once cloudflared is running.

a. Create the cloudflared systemd service

Terminal window
nano /etc/systemd/system/cloudflared.service

Replace <TUNNEL_UUID> with your actual tunnel ID:

cloudflared.service
[Unit]
Description=cloudflared
After=network-online.target
Wants=network-online.target
[Service]
TimeoutStartSec=15
Type=notify
ExecStart=/usr/bin/cloudflared --no-autoupdate tunnel run <TUNNEL_UUID>
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target

b. Reload systemd, then enable and start service

systemctl daemon-reload
systemctl enable cloudflared --now

Your blog should now be up and running.

Resource usage after deployment#

Now let’s check the disk and memory usage after deployment:

root@localhost:~# df -h
Filesystem Size Used Avail Use% Mounted on
/dev/vfg 975M 632M 292M 69% /
none 492K 4.0K 488K 1% /dev
devtmpfs 1.9G 0 1.9G 0% /dev/net
tmpfs 2.0G 0 2.0G 0% /dev/shm
tmpfs 785M 88K 785M 1% /run
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 393M 0 393M 0% /run/user/0
root@localhost:~# free -h
total used free shared buff/cache available
Mem: 256Mi 40Mi 127Mi 0.0Ki 88Mi 215Mi
Swap: 256Mi 4.0Mi 251Mi

After everything is in place, the VPS is still running within a comfortable range. Disk usage has increased, as expected, but there is still enough free space left for a small static blog. Memory usage also remains low, which is exactly what we want on a 256 MiB VPS.

Final thoughts#

For something that costs only $2 per year, I think the result is already quite satisfying. With just 256 MiB of memory and 1 GiB of disk space, it is still enough to host a small static blog comfortably.

This setup keeps the VPS side simple: GitHub Actions builds the site, rsync deploys it, Nginx serves it locally, and cloudflared publishes it through Cloudflare Tunnel. With only two exposed ports and one of them already taken by SSH, this feels like the most practical way to put the VPS to use.

I am not sure what else I should put on this tiny VPS, so for now, I will just leave it as it is.

TIP

If you are buying an ultra-cheap VPS like this, check the provider’s reputation carefully and assume there is some risk around reliability or data loss.
I am intentionally leaving out the provider name to avoid any affiliate issue.

Hosting Astro Blog on Tiny VPS with GitHub Actions, Nginx, and Cloudflare Tunnel
https://blog.joeyc.dev/posts/astro-blog-tiny-vps/
Author
Joey Chen
Published at
2026-04-24
License
CC BY-SA 4.0