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-blogviarsync - Serve static files with Nginx on
127.0.0.1:8080 - Expose the service through
cloudflaredand 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-virtlxcThen check how much memory is available:
root@localhost:~# free -h total used free shared buff/cache availablesMem: 256Mi 16Mi 108Mi 0.0Ki 131Mi 239MiSwap: 256Mi 0.0Ki 255MiNext, check the disk space:
root@localhost:~# df -hFilesystem Size Used Avail Use% Mounted on/dev/vfg 975M 459M 465M 50% /none 492K 4.0K 488K 1% /devdevtmpfs 1.9G 0 1.9G 0% /dev/nettmpfs 2.0G 0 2.0G 0% /dev/shmtmpfs 785M 76K 785M 1% /runtmpfs 5.0M 0 5.0M 0% /run/locktmpfs 393M 0 393M 0% /run/user/0This 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.
WARNINGThis 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
apt install rsyncb. Create a dedicated deploy user
sudo adduser --disabled-password --gecos "" deployc. Prepare the target directory
sudo mkdir -p /var/www/fuwari-blogsudo chown -R deploy:deploy /var/www/fuwari-blogd. Switch to deploy user and generate an SSH key pair for deployment
sudo -iu deployssh-keygen -t ed25519The key pair will be stored in the default location:
~/.ssh/id_ed25519~/.ssh/id_ed25519.pube. Allow this key to log in over SSH
cp ~/.ssh/id_ed25519.pub ~/.ssh/authorized_keysCI/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 example123.45.67.89VPS_PORT: SSH port, for example22VPS_USER: deployment user, for exampledeployVPS_TARGET_DIR: target directory on VPS, for example/var/www/fuwari-blogVPS_SSH_KEY: private SSH key used for deployment
Set up GitHub Actions
Create .github/workflows/deploy.yml with the following workflow:
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: ./deploy26 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_hostsThis workflow is split into two jobs:
buildinstalls dependencies and generates the static site indist- The generated site is uploaded as a workflow artifact
deploydownloads the artifact and syncs it to the VPS- A size check helps prevent oversized deployments on a very small server
- SSH key and
known_hostsfile 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:
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 nginxConfigure 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
rm /etc/nginx/conf.d/default.confnano /etc/nginx/conf.d/fuwari-blog.confb. Use following configuration to serve the blog on 127.0.0.1:8080
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
nginx -tsystemctl enable nginx --nowIf Nginx is already running before any change is made, reload it to apply new configuration:
systemctl reload nginxPrepare 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:
sudo mkdir -p --mode=0755 /usr/share/keyringscurl -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 cloudflaredQuick 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:
cloudflared tunnel --url http://localhost:80802026-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-apps2026-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: amd642026-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
CAUTIONThe 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:
sudo cloudflared service install abcdefghijklmnopqrstuvwxyzThis command creates a systemd service that starts automatically at boot:
# /etc/systemd/system/cloudflared.service[Unit]Description=cloudflaredAfter=network-online.targetWants=network-online.target
[Service]TimeoutStartSec=15Type=notifyExecStart=/usr/bin/cloudflared --no-autoupdate tunnel run --token abcdefghijklmnopqrstuvwxyzRestart=on-failureRestartSec=5s
[Install]WantedBy=multi-user.targetWhen you inspect the process information, the token is embedded in the command line:
ps -fp "$(systemctl show -p MainPID --value cloudflared)"UID PID PPID C STIME TTY TIME CMDroot 7668 1 0 06:57 ? 00:00:00 /usr/bin/cloudflared --no-autoupdate tunnel run --token abcdefghijklmnopqrstuvwxyzcloudflared 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.
NOTEConsidered 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
cloudflared tunnel loginb. 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.pemc. Create the tunnel
cloudflared tunnel create fuwari-blogThe 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:
ls ~/.cloudflared/<TUNNEL-UUID>.json cert.pemd. Run the tunnel in the foreground to verify the connection
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: amd642026-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-b3173274aa9c2026-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 requests2026-04-14T05:17:24Z INF Initial protocol quic2026-04-14T05:17:24Z INF ICMP proxy will use 10.0.1.4 as source for IPv42026-04-14T05:17:24Z INF ICMP proxy will use 2001:157:a:135::56b2 in zone eth0 as source for IPv62026-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 IPv42026-04-14T05:17:24Z INF ICMP proxy will use 2001:157:a:135::56b2 in zone eth0 as source for IPv62026-04-14T05:17:24Z INF Starting metrics server on 127.0.0.1:20241/metrics2026-04-14T05:17:24Z INF Tunnel connection curve preferences: [X25519MLKEM768 CurveP256] connIndex=0 event=0 ip=198.41.199.1932026-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=quic2026-04-14T05:17:24Z INF Tunnel connection curve preferences: [X25519MLKEM768 CurveP256] connIndex=1 event=0 ip=198.41.192.512026-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=quic2026-04-14T05:17:25Z INF Tunnel connection curve preferences: [X25519MLKEM768 CurveP256] connIndex=2 event=0 ip=198.41.192.1242026-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=quic2026-04-14T05:17:26Z INF Tunnel connection curve preferences: [X25519MLKEM768 CurveP256] connIndex=3 event=0 ip=198.41.199.622026-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=quicMigrate from the legacy Cloudflare tunnel
a. Move the tunnel credential into /etc/cloudflared and remove account certificate file
mkdir /etc/cloudflared/mv ~/.cloudflared/*.json /etc/cloudflared/rm ~/.cloudflared/cert.pemNOTEAlso 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.
TIPIf 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
nano /etc/systemd/system/cloudflared.serviceReplace <TUNNEL_UUID> with your actual tunnel ID:
[Unit]Description=cloudflaredAfter=network-online.targetWants=network-online.target
[Service]TimeoutStartSec=15Type=notifyExecStart=/usr/bin/cloudflared --no-autoupdate tunnel run <TUNNEL_UUID>Restart=on-failureRestartSec=5s
[Install]WantedBy=multi-user.targetb. Reload systemd, then enable and start service
systemctl daemon-reloadsystemctl enable cloudflared --nowYour 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 -hFilesystem Size Used Avail Use% Mounted on/dev/vfg 975M 632M 292M 69% /none 492K 4.0K 488K 1% /devdevtmpfs 1.9G 0 1.9G 0% /dev/nettmpfs 2.0G 0 2.0G 0% /dev/shmtmpfs 785M 88K 785M 1% /runtmpfs 5.0M 0 5.0M 0% /run/locktmpfs 393M 0 393M 0% /run/user/0root@localhost:~# free -h total used free shared buff/cache availableMem: 256Mi 40Mi 127Mi 0.0Ki 88Mi 215MiSwap: 256Mi 4.0Mi 251MiAfter 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.
TIPIf 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.