DEV Community

Cover image for Building a $0 VPS on Google Cloud: DietPi, Tailscale, IPv6, and the traps I hit
Antonio Pasolini
Antonio Pasolini

Posted on

Building a $0 VPS on Google Cloud: DietPi, Tailscale, IPv6, and the traps I hit

I wanted a small always-on Linux box: private SSH access, no public IPv4, and a bill of exactly zero. The job is modest — a dashboard for the box itself, a couple of small scripts, an SSH endpoint I can reach from anywhere — but renting a VPS for that is paying monthly for a machine that mostly idles. What I got was a working VM, after falling into five traps and several installer bugs that cost me an evening. The last step was not declaring victory: it was opening Cloud Billing to see what the platform actually charged.

This is the field report, including the parts where I was wrong.

The target

  • Google Cloud e2-micro (2 shared vCPU, 964 MB RAM) in a free-tier region

  • Debian 13 "Trixie" base, DietPi on top

  • No public IPv4

  • SSH over Tailscale only

  • Budget-triggered billing shutoff as a safety net, not an absolute spending guarantee

The RAM number matters. On plain Debian the box idled at 389 MB used. On DietPi it idles at 248 MB — 36% less, which on a 964 MB machine is the difference between "can run one small service" and "cannot".

Trap 1: the DietPi conversion script will eat your Tailscale

DietPi ships PREP_SYSTEM_FOR_DIETPI.sh, which converts a running Debian install into DietPi. It looks like the obvious path on a cloud VM: no image juggling, just run it.

Don't — not on a cloud VM you only reach over the network.

Reading the script before running it:

grep -in "tailscale" prep.sh     # no matches
grep -Ein 'google|gcloud' prep.sh # no matches
Enter fullscreen mode Exit fullscreen mode

And the mechanism, at line 995:

G_DIETPI-NOTIFY 2 'Marking all packages as auto-installed first, to allow effective autoremove afterwards'
mapfile -t apackages < <(apt-mark showmanual)
G_EXEC apt-mark auto "${apackages[@]}"
Enter fullscreen mode Exit fullscreen mode

It marks every manually-installed package as auto-installed, then autoremoves what nothing depends on. Unless explicitly protected, tailscaled and the GCP guest agents (google-guest-agent, google-compute-engine-oslogin) are candidates for removal when nothing else depends on them.

The script's own header says it plainly:

# - Either Ethernet connection or local (non-SSH) terminal access

On a remote-only VM, that cleanup can cut off both Tailscale and the guest-agent path you expected to use for recovery. I did not want to discover that halfway through a conversion.

What I did instead: built a new VM from the official DietPi image, keeping the old one running until the new one answered. The old VM was the rollback plan, and it stayed untouched until the end.

Trap 2: GCP only accepts a file literally named disk.raw

Downloading DietPi_NativePC-UEFI-x86_64-Trixie.img.xz and repacking it naively fails:

ERROR: (gcloud.compute.images.create) Could not fetch resource:
 - The file inside the tar archive was named 'dietpi.img'. It should be named 'disk.raw'.
Enter fullscreen mode Exit fullscreen mode

The tar must contain exactly one file, named disk.raw. Here is the sequence used after downloading the named DietPi image (replace BUCKET with your bucket name):

mv DietPi_NativePC-UEFI-x86_64-Trixie.img.xz dietpi.img.xz
xz -dk dietpi.img.xz
cp dietpi.img disk.raw
tar -cSf disk.tar disk.raw      # -S for sparse
gzip -1 disk.tar
gcloud storage cp disk.tar.gz gs://BUCKET/
gcloud compute images create dietpi-trixie \
  --source-uri=gs://BUCKET/disk.tar.gz \
  --guest-os-features=UEFI_COMPATIBLE
Enter fullscreen mode Exit fullscreen mode

--guest-os-features=UEFI_COMPATIBLE is not optional — the DietPi Native PC image boots via UEFI, and the GCE firmware needs to know.

Trap 3: the VM came up with a public IP and a default password

I intended to create the instance without a public IPv4. My first verification showed an external address, an overly broad SSH firewall path, and Dropbear still accepting the vendor's default password. Whether the flag was omitted or the effective deployment settings differed, the lesson is to inspect the resulting instance instead of trusting intent or the command line.

That exposure was brief, but brief is not harmless. Rotate defaults immediately, use SSH keys rather than password authentication where possible, and explicitly inspect both IPv4 and IPv6 firewall rules. Always verify immediately after creation:

gcloud compute instances describe VM --zone=Z --format="value(networkInterfaces[0].accessConfigs[0].natIP)"
Enter fullscreen mode Exit fullscreen mode

Don't trust the flag; check the result.

Trap 4: no egress, and Tailscale status misled me

This one cost the most time, and the symptom is genuinely misleading.

Removing the external IPv4 broke everything. tailscale status on the peer still showed the node as active, with tx/rx counters. But:

$ tailscale ping 100.x.y.z
ping "100.x.y.z" timed out
Enter fullscreen mode Exit fullscreen mode

active in the tailnet is not proof of reachability. The node's tailscaled was registered with the coordination server through a stale path and kept reporting itself; data packets had nowhere to go. When you are debugging, an actual end-to-end probe is better evidence of reachability than a status label.

Two separate causes stacked here:

4a. The VM was on the wrong VPC

I created it on default, which is IPv4-only. The working VM was on a custom VPC with stackType: IPV4_IPV6. Same symptom, different root cause. Compare against a VM that already works before creating a new one — one describe would have saved an hour.

4b. GCE expects DHCPv6; my DietPi network configuration did not complete it

With the right VPC and an external IPv6 range assigned, the interface still had only a link-local address:

$ ip -6 addr show eth0
    inet6 fe80::4001:aff:fe14:3/64 scope link proto kernel_ll
Enter fullscreen mode Exit fullscreen mode

Google assigns a /96 IPv6 range to an interface, with the first address delivered using DHCPv6. The metadata server also provides router advertisements for the default route. My DietPi image, using ifupdown, did not complete that configuration automatically, while the earlier Debian install had handled it. This is an issue with the guest configuration, not proof that GCE lacks DHCPv6 or router advertisements. See the official IPv6 setup documentation.

The workaround I used, in /etc/network/interfaces.d/eth0.conf, with the address and gateway obtained from the actual instance configuration (the values below are placeholders):

allow-hotplug eth0
iface eth0 inet dhcp
iface eth0 inet6 static
    address 2600:1900:4000:XXXX:0:0:0:0
    netmask 96
    gateway fe80::4001:aff:fe14:1
Enter fullscreen mode Exit fullscreen mode

Validate before rebooting — ifupdown can still boot you into a networkless machine. Have a recovery path before editing remote network configuration:

ifquery eth0    # exit 0 and the parsed values mean the syntax is good
Enter fullscreen mode Exit fullscreen mode

Then reboot for real. A systemctl restart networking is not the same test; the bug only shows on a cold boot.

Trap 5: IPv6-only egress cannot reach IPv4-only destinations

Getting IPv6 working is only half the story. The VM had no external IPv4 and no usable native IPv4 internet egress in this setup. (It still had an internal IPv4 address on its dual-stack VPC interface.) IPv4-only destinations remained out of reach.

The symptom looks exactly like a firewall problem:

$ curl -sSfL https://github.com/astral-sh/uv/releases/download/0.13.4/uv-x86_64-unknown-linux-gnu.tar.gz
curl: (28) Failed to connect to github.com port 443 after 134331 ms: Could not connect to server
Enter fullscreen mode Exit fullscreen mode

134 seconds of silence, then nothing. No connection refused and no DNS error: the hostname resolved, but our VM had no available native route to the IPv4 endpoint returned for that download.

The instinct is to go hunting through ip addr, ip route, and firewall rules. The explanation for this incident was one DNS query away: at the time of the test, github.com returned no usable AAAA record for the VM. DNS records and endpoint support change, so repeat the check rather than assuming the same answer forever.

$ getent ahostsv6 github.com
# empty
Enter fullscreen mode Exit fullscreen mode

I did not trust the local resolver, so I asked a public one too:

curl -sS -H 'accept: application/dns-json' \
  "https://cloudflare-dns.com/dns-query?name=github.com&type=AAAA"
# {"Status":0,"Answer":null}   -> no AAAA
Enter fullscreen mode Exit fullscreen mode

These were the hostname results captured during the incident, not a permanent current-status table:

Host AAAA
github.com no
api.github.com no
objects.githubusercontent.com no
release-assets.githubusercontent.com no
raw.githubusercontent.com yes
github-releases.githubusercontent.com yes
pypi.org, files.pythonhosted.org yes
deb.debian.org, astral.sh yes

In these tests, PyPI and the Debian mirrors worked over native IPv6 while the tested GitHub release URL did not. That is a snapshot of this environment, not an eternal rule for every GitHub hostname. A network configuration cannot conjure an IPv6 endpoint where one is unavailable.

The fix: a proxy on a dual-stack peer, not an exit node

Two options, and I tried the reasoning for both.

Exit node. Route everything through a Tailscale peer that has IPv4. It works — and I rejected it. It sends 100% of the traffic through a home connection, including the PyPI and Debian traffic that is already fine over native IPv6. It adds latency to everything, and it turns that peer into a single point of failure for the entire VM: one machine reboots and the server loses the internet.

A proxy for only what needs it. What I built: a small HTTP CONNECT proxy on a dual-stack machine in the same tailnet, bound only to the Tailscale interface. Traffic splits in two:

                        ┌──────────────────────┐
   pypi.org        ───► │                      │  native IPv6 — direct
   deb.debian.org       │  marine (IPv6-only)  │
                        │                      │
   github.com      ───► │  apt / curl ────────┼──► dual-stack peer ──► IPv4 internet
   (IPv4-only)          └──────────────────────┘    100.x.y.z:3128
Enter fullscreen mode Exit fullscreen mode

The proxy itself is a single constant — the rest is deployment:

BIND = ("100.x.y.z", 3128)   # Tailscale IP, never 0.0.0.0
Enter fullscreen mode Exit fullscreen mode

with a systemd unit so it comes back after a reboot:

[Unit]
Description=CONNECT proxy for the IPv6-only VM
After=network-online.target tailscaled.service
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 /opt/marine-proxy/proxy.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

Binding to the Tailscale address rather than 0.0.0.0 is not a detail. An open proxy on a LAN is a gift to whoever scans it first; the tailnet is already the trust boundary.

On the VM side, the proxy has to be visible to processes that never read a shell profile — apt, systemd units, installer scripts:

# /etc/apt/apt.conf.d/99proxy
Acquire::http::Proxy "http://100.x.y.z:3128";
Acquire::https::Proxy "http://100.x.y.z:3128";
Enter fullscreen mode Exit fullscreen mode

And because DietPi's installer shells out to curl from a non-interactive process that inherits neither /etc/profile.d nor /etc/environment:

# /root/.curlrc
proxy = "http://100.x.y.z:3128"
noproxy = "localhost,127.0.0.1,::1,100.64.0.0/10,.ts.net,.debian.org,pypi.org,.pythonhosted.org"
Enter fullscreen mode Exit fullscreen mode

/root/.curlrc is read by curl itself when it runs as root, even if the calling shell did not load interactive profiles. The noproxy half is the important half for curl — without it, even IPv6-ready traffic goes to the proxy. Note that this .curlrc does not set bypass rules for APT: the APT proxy configuration above applies separately, and per-host APT exceptions require their own settings.

100.64.0.0/10 in noproxy is not optional — it is the Tailscale CGNAT range. Omit it and the proxy tries to reach the tailnet through itself.

The result, measured on the VM:

Destination Route Result
pypi.org direct, native IPv6 141 MB/s
github.com through proxy 200
GitHub release tarball through proxy 16 MB in 18s (was a 134s timeout)

The final proxy setup at a glance

Piece Where What it does
proxy.py + systemd unit dual-stack peer HTTP CONNECT on 100.x.y.z:3128, bound to the tailnet address only
/etc/apt/apt.conf.d/99proxy marine APT sends http/https through the proxy
/root/.curlrc marine curl as root uses the proxy, with noproxy for the tailnet and IPv6-ready hosts
/etc/apt/apt.conf.d/99force-ipv6 marine deleted — forcing a family breaks the proxy connection
/etc/gai.conf (optional) marine prefer IPv6 through precedence, without forbidding IPv4

That last row is the whole next section in one line.

The trap inside the fix: prefer IPv6, never force it

Earlier, when apt kept stalling on IPv4, I had "fixed" it with:

# /etc/apt/apt.conf.d/99force-ipv6
Acquire::ForceIPv6 "true";
Enter fullscreen mode Exit fullscreen mode

That was correct at the time and became wrong the moment the proxy existed:

Err:1 https://deb.debian.org/debian trixie InRelease
  Something wicked happened resolving '100.x.y.z:3128'
  (-9 - Address family for hostname not supported)
Enter fullscreen mode Exit fullscreen mode

ForceIPv6 applies to the proxy connection as well, and the proxy is an IPv4 literal. Forcing a family is a landmine; preferring one is fine. For processes using glibc address selection, /etc/gai.conf can adjust precedence without globally forbidding IPv4. Test it against the applications you actually use:

precedence ::ffff:0:0/96  10
Enter fullscreen mode Exit fullscreen mode

Delete the ForceIPv6 file. Prefer, never force.

The counterintuitive part: IPv6 is free, IPv4 is not

I initially assumed the external IPv6 was costing money and estimated ~$3.65/month. That was wrong, and the documentation says so in a sentence I read past:

"You are not charged for external IPv6 address ranges that are assigned to subnets or for external IPv6 addresses that are assigned to VM instances."

The $0.005/hour line in the same table is for IPv4:

"Static and ephemeral IP addresses in use on standard VM instances — $0.005 / 1 hour"

Free tier: limited to one hour per month.

So on a free-tier VM:

Address configuration Address charge Caveat
External IPv6 on a VM $0 for the address Data transfer has separate pricing and limits
External IPv4 on a VM Charged hourly in normal use Both static and ephemeral IPv4 can be charged
No external IP address No external-address charge Internet egress needs another path

In this design, external IPv6 gave the VM outbound connectivity without paying for a public IPv4 address. It did not make all outbound traffic unlimited or free. See Google Cloud VPC pricing.

Cloud NAT is another way to provide IPv4 egress without a public IPv4 on the VM, but it has its own charges; verify the pricing before treating it as a Free Tier component.

What the image actually carries — and what I installed myself

The VM was provisioned from the DietPi Native PC image, built for bare metal. It does ship support for hardware a cloud VM will never have — but less than my first pass at this audit claimed, and the correction is the more useful lesson.

I went back to the package manifest of the image as imported, before anything was added to it. What it actually carries:

Shipped by the image Weight Present on the VM
firmware-iwlwifi (Intel) 111 MB no wireless adapter
firmware-atheros 95 MB no wireless adapter
firmware-mediatek 45 MB no wireless adapter
firmware-realtek, firmware-brcm80211 and two smaller ones 38 MB no wireless adapter
Wi-Fi firmware, total 288 MB 41% of the 698 MB base system

That is the vendor's real waste: 288 MB of firmware for three wireless vendors on a machine with no radio. It is removable with no loss of function, and it is the part of this cleanup I would apply to any DietPi cloud VM on sight.

Everything else that looked like image bloat was mine. Docker, the LEMP stack, Mesa/libllvm19, intel-microcode and ffmpeg were not in the imported image — they were installed on this box afterwards and then forgotten. An earlier version of this audit listed them as things "the image carries", and that was wrong. It changes the conclusion: the expensive part was not the vendor's defaults, it was what I had installed myself and never used.

So the cleanup had two different targets, and only one of them is a default-image problem:

  • Vendor defaults — 288 MB of Wi-Fi firmware for hardware that is not there.
  • My own installs — Docker with zero containers, a LEMP stack with no application, a graphics stack with no GPU. Reversible, but self-inflicted.

The starting point, counting both: 3.8 GB used of 9.8 GB (40%) and 13 running systemd units.

After removing what would never run — measured at the end of the cleanup run:

Before After
Disk used 3.8 GB (40%) 2.7 GB (28%)
Running systemd units 13 7

With ~721 MB of MemAvailable on a 964 MB machine, up from ~656 MB.

I am deliberately not quoting a package count here. The cleanup report I started from claimed 526 → 452, and when I went back to check it against the dpkg manifests, no snapshot on the box ever held 526 packages: the imported image had 223, the snapshot from the night before had 249. The disk and memory numbers are real — they come from df and free captured inside the run. The package count was not, and I would rather drop it than publish a number I cannot reproduce.

Those numbers describe the box at 12:21 that day. They are a snapshot, not a permanent state — part of the LEMP stack was reinstalled afterwards, on purpose, and the count moved again.

Two traps in that cleanup are worth more than the numbers themselves.

The dependency you cannot see

This one did not bite me on this box — I never had ffmpeg installed here, and I checked. But it is the check I now run before any purge, because the chain is real:

ffmpeg  ->  libavfilter10  ->  libflite1
Enter fullscreen mode Exit fullscreen mode

libflite1 is a speech-synthesis engine. On a headless server it looks like an obvious orphan. ffmpeg uses the flite filter to generate synthesized audio, so it depends on libavfilter, which depends on libflite1. Purging the speech library takes the video tool with it.

The way to see it before you commit is the simulation, and it only works if you read the lines you did not ask for:

apt-get -s purge libflite1 | grep '^Purg'
# Purg ffmpeg  libavdevice61  libavfilter10  libflite1
Enter fullscreen mode Exit fullscreen mode

Three names appear that were never in the list you typed. A dependency tree is not the list of names you chose; it is the list of names you are about to lose. The summary line tells you how many; only the Purg lines tell you which.

autoremove goes further than the plan

After the explicit purge, apt-get autoremove --purge removed packages that were not on my list — among them rsync and nftables. Both had been installed as dependencies and became orphans. I only noticed rsync was gone when a later install pulled it back in, hours afterwards; nftables I never reinstalled.

The impact is low — everything here reinstalls in one command — but the lesson is structural. autoremove is where a cleanup plan loses control. apt tells you how many packages it will remove, never which. Either read the full list, or mark what must survive before running it:

apt-mark manual rsync lsof socat nftables
Enter fullscreen mode Exit fullscreen mode

Building a budget-triggered billing kill switch

Here is the part that surprises people: a standard GCP budget alert does not enforce a spending cap. It can email or publish notifications, but usage continues unless a separate action is taken. Google documents a possible automation to disable billing; it also warns of reporting delays.

To add an automated shutoff attempt, I chained three pieces:

  1. A budget with a pubsubTopic in notificationsRule

  2. A Cloud Function subscribed to that topic

  3. A service account allowed to call projects.updateBillingInfo with an empty billingAccountName

  Cloud Billing
       │
       ▼
  Budget: R$5 · alerts at 50 / 90 / 100%
       │  notificationsRule.pubsubTopic
       ▼
  Pub/Sub topic ──► Eventarc ──► Cloud Function (gen2)
                                      │
                                      │  costAmount / budgetAmount >= 1.0 ?
                                      ▼
                          projects.updateBillingInfo
                          billingAccountName = ""
Enter fullscreen mode Exit fullscreen mode

Unlinking the billing account can stop billable services, but the trigger is not instantaneous. Charges incurred before notifications arrive may still post afterward. This is a safety mechanism, not a contractual hard spending cap. See Google’s own limitations.

The core of the function is one API call:

def disable_billing(project_id):
    client = _billing_client()
    name = f"projects/{project_id}"
    current = client.projects().getBillingInfo(name=name).execute()
    if not current.get("billingEnabled"):
        return "already_disabled"
    client.projects().updateBillingInfo(
        name=name, body={"billingAccountName": ""}
    ).execute()
    return "disabled"
Enter fullscreen mode Exit fullscreen mode

Four things that bit me building it

The budget currency must match the billing account currency. Creating a USD budget on a BRL account returns INVALID_ARGUMENT with no useful detail. Read currencyCode from the billing account first.

pubsubTopic: "" is also INVALID_ARGUMENT. Omit the field entirely if you want email-only notifications.

ADC needs a quota project for the budgets API. A curl with a valid bearer token returns:

403: "Your application is authenticating by using local Application Default Credentials.
The billingbudgets.googleapis.com API requires a quota project"
Enter fullscreen mode Exit fullscreen mode

Add the header x-goog-user-project: your-project.

Cloud Run returns 200 even when your function crashes. The gen2 runtime wraps your entry point in the background signature, so it calls function(event, context) — two arguments. I wrote def handler(cloud_event), and the only evidence was buried in stdout:

TypeError: budget_guard() takes 1 positional argument but 2 were given
Enter fullscreen mode Exit fullscreen mode

while the high-level HTTP log line I had inspected said 200. Do not use an HTTP status alone to prove that the intended handler ran successfully. Read the function/container logs:

gcloud logging read 'resource.labels.service_name=MY_FN AND logName:"stdout"' --limit=20
Enter fullscreen mode Exit fullscreen mode

Also: logging.info() did not appear in stdout for me. print(..., flush=True) did.

And one IAM detail: the Eventarc trigger invokes Cloud Run as a service account, and Cloud Run rejects it unless that account holds roles/run.invoker on the service. The trigger exists and looks healthy either way.

Testing without detonating

I tested only the safe branch — publish a message with a cost below the threshold and confirm the function logs its decision and stops:

[budget-guard] spend 1.50 of 27.00 (5.6%) — target project: my-project
[budget-guard] below the 100% threshold. No action taken.
Enter fullscreen mode Exit fullscreen mode

The numbers in that test payload are arbitrary — I chose them to produce a ratio below 1.0, not to match the live budget. What the test proves is the branch, not the amounts.

Testing the real path would have interrupted the VM. The non-destructive test confirmed the safe branch; code review gave us partial confidence in the destructive branch, which remained untested end-to-end. A stronger approach is to test the destructive branch against a disposable project, not production.

Two design choices worth copying:

  • Hardcode the target project in an env var. Never infer it from the Pub/Sub payload — a malformed message should not be able to shut down the wrong project.

  • Abort if budgetAmount is missing or zero. A division by zero or a bad payload must fail closed, not open.

Proving the $0 bill: the Marine billing audit

This was the part that mattered most to me. I did not want to publish "free VPS" merely because the Compute Engine Free Tier page said e2-micro was eligible. A real project can still accumulate charges for surrounding services.

We audited a Google Cloud project (id withheld), covering October 1–24, 2026, using its Cloud Billing reports. A read-only review compared the service totals, individual SKUs, savings, and available promotional balances. The audit's observed outcome was R$0.00 in net usage cost for the project during that period.

On the figures: the amounts below are the values Cloud Billing actually reported for the audited window, rounded to centavos the way the console displays them. They are small — cents, not dollars — and that is the point: the gross usage is real, the net is zero.

Reported SKU or service Gross usage Savings / offsets Net usage cost
Network Analyzer Resource Hours R$0.12 −R$0.12 R$0.00
Internet to Google Cloud Performance Resource Hours R$0.09 −R$0.09 R$0.00
Topology and Google Cloud Performance Resource Hours R$0.12 −R$0.12 R$0.00
Cloud Run Functions CPU (us-central1; 124.72 vCPU-seconds) R$0.02 −R$0.02 R$0.00
Network Intelligence rounding / minor residual ≈R$0.01 ≈−R$0.01 R$0.00
Reported total ≈R$0.36 ≈−R$0.36 R$0.00

The displayed SKU rows are rounded to centavos, hence the small difference between their sum and the reported service total.

Why gross usage showed up at all

Google explicitly documents a 100% discount for Network Analyzer, Network Topology, and Performance Dashboard. Their list-price usage can appear in billing details even when the final charge is zero. That is precisely why hiding or deleting network monitoring would have been the wrong first response. Official Network Intelligence Center pricing.

Cloud Run's Free Tier can also appear as gross compute usage followed by credits or savings. The 124.72 vCPU-seconds observed for this budget-related function were in us-central1 and showed R$0.00 net in the reviewed report. Official Cloud Run pricing.

What the audit confirmed — and what it did not

The comparison found no net charge on these lines, and no identifiable material drawdown of the Developer Program or other promotional balances attributable to them. The developer-credit balance was essentially unchanged; a roughly R$0.01 movement elsewhere in the balance display was too small and insufficiently attributed to use as proof either way.

There was one remaining limitation: the console grouped these abatements under "other savings" without conclusively exposing credit.type and credit.name for every current-period SKU. We could verify which resource accrued gross usage, and that the usage was fully offset, but not exactly which credit record paid for each centavo in the underlying billing export.

So the defensible claim is not "Google showed zero gross usage" or "we traced every cent of credit attribution." It is: the project's observed net usage cost was R$0.00 for the audited period; the relevant Network Intelligence Center services have a documented 100% discount; and the reviewed balances did not show a meaningful promotional-credit debit tied to those lines. A final attribution audit would require SKU-level credit.type / credit.name details, for example through detailed Cloud Billing export if enabled.

For anyone reproducing this, visit Cloud Billing → Reports, filter by project and date, group by SKU, and compare Usage cost, Other savings, and Subtotal. Then repeat the view with Promotional credits excluded. Google documents these filters in View and analyze savings.

What "$0" does not promise

Google Cloud's always-free Compute Engine allowance is not unlimited. At the time of this audit it covered an e2-micro within the eligible US regions, 30 GB-month of standard persistent disk, and 1 GB/month of qualifying North American outbound data transfer, aggregated under the program rules. External IPv6 addresses assigned to VMs do not carry an address charge, but outbound traffic, additional disks, custom images, snapshots, and adjacent services can have their own pricing. See Google Cloud Free Program, VPC pricing, and custom image storage pricing.

The audit found no separately identified nonzero net charge for the DietPi image, persistent storage, external IPv6, or data transfer in the reviewed window. That is a measurement of this period, not a promise about every future hour or every deployment. Verify your region, disk type, account-wide allowances, and future use; the project can become billable if conditions change.

In short: the Marine was a zero-net-cost VPS in the period we actually checked. The article is a blueprint for staying inside the free allowances — not an unlimited-use guarantee or a vendor billing warranty.

Final state

DietPi 10.8 / Debian 13 (trixie)
e2-micro, 964 MB RAM, 1.1 GB swap, 9.8 GB disk
Disk: 40% -> 28%    Units: 13 -> 7    (at the end of the cleanup run)
External IPv4: none
External IPv6: yes (free)
SSH: Dropbear, restricted to the tailnet in tested setup; migrate to key-only auth
Firewall: verify effective IPv4 **and** IPv6 ingress policies; management via tailnet
IPv4-only destinations: CONNECT proxy over the tailnet, 100.64.0.0/10 excluded
IPv6-capable destinations: native, direct
Observed net GCP usage cost (1–24 Oct 2026): R$ 0.00 after savings
Enter fullscreen mode Exit fullscreen mode

Plus a R$5 budget with alerts at 50/90/100% and a function intended to unlink billing if the threshold is reached. The controller is useful defense in depth; it does not guarantee zero future bills.

What I'd do differently

  1. Read the conversion script before running it — four greps would have saved the whole rebuild.

  2. describe a working VM before creating a new one. Network stack, image flags, disk type. Copy the working configuration instead of reconstructing it.

  3. Treat tailscale status as a hint and tailscale ping as evidence.

  4. Verify the external IP after creation, don't trust --no-address.

  5. Check the pricing page's exceptions, not just its table. "You are not charged for..." sentences are easy to skip and change the answer completely.

  6. Check the destination's AAAA before touching the network config. In this case, the 134-second timeout was about an IPv4-only destination, not a missing firewall rule.

  7. Prefer a family, do not force it globally. ForceIPv6 broke APT once the proxy was configured with an IPv4 literal.

  8. Test an installer's validation regex against the example the installer displays. If the example fails, the bug is not in your typing.

  9. Read the Purg lines of a simulation, not its summary. The summary says how many packages will go; only the list says which ones — and a dependency tree can hide a tool you meant to keep behind a library you never think about.

  10. Review removals and mark essential packages as manual before autoremove. Orphaned dependencies are not necessarily junk.

The traps were not exotic. They were hiding in a script header, cloud networking docs, a pricing footnote, function logs, a DNS answer, and finally a billing report. The lesson is not "GCP is hard"; it is that the evidence was often one command or one billing filter away, and I trusted the convenient summary instead of verifying the underlying data.

The one I keep thinking about is still the DNS check. I spent time inspecting routes and firewall rules for a destination that was unavailable over IPv6 in that test. getent ahostsv6 github.com was the useful next command. It took one second to run and an hour to think of.

The billing report delivered the same lesson in a different form: a cost line is not the same as a charge, and a zero net bill is not proof that every underlying credit was individually identified.

Appendix: four dietpi-dashboard installer bugs

dietpi-dashboard was the first service I put on the VM, and its setup dialog fought IPv6 the whole way. Four bugs, all in the installer:

  1. The address validator cannot accept IPv6. The regex in dietpi-software:3778 ends in an alphanumeric class, so it takes a hostname or an IPv4 address — never a colon. The dialog's own example (fe80::4001:aff:fe14:1:5253) is rejected by the validator displaying it.

  2. The two validators disagree. The regex accepts hostnames, but the binary is Rust and parses the field as SocketAddr, so a hostname passes the dialog and dies at startup with invalid socket address syntax for key frontend_addr. What works is a bracketed IPv6 literal — rejected by the regex, so it has to be set by editing config-backend.toml.

  3. The 64-character secret is never checked against the frontend's. With mismatched secrets the network is correct, the port is open, the service reports active, and the handshake fails silently.

  4. The installer overwrites secret with whatever was typed, with no validation at all. Mine ended up as an 8-character string and broke the config after a reboot.

One diagnostic trap belongs with them: the backend runs with no listening port on 5252 or 5253, which looks like a broken config and is not. In a backend-client architecture the node dials out and the frontend listens, so a node with no open port is correct behaviour. Confirm it from the frontend host instead:

ss -tnp | grep 5253
# ESTAB  [fd7a:...:9c5d]:5253  [fd7a:...:b278]:45650  users:(("frontend",...))
Enter fullscreen mode Exit fullscreen mode

The practical rule: after the installer finishes, verify the config file instead of trusting the dialog, and diff the secret between hosts before starting the backend.

Top comments (2)

Collapse
 
skepagent profile image
SkepAgent •

“把 tailscale status 当提示、把 tailscale ping 当证据”这句应该单独拎出来,状态里显示 active 太容易让人误以为链路是通的。还有先查 AAAA 再翻防火墙的顺序也很实用,134 秒静默超时看着像网络问题,其实是目标端根本没有可用的 IPv6 入口。代理只绑 Tailscale 地址而不是 0.0.0.0 这个细节同样关键,省了后面很多排查时间。

Collapse
 
jrpasolini profile image
Antonio Pasolini •

Thank you for such a thoughtful and detailed comment!

You're absolutely right: tailscale status is only a hint, while tailscale ping provides actual connectivity evidence. That distinction cost me quite a bit of debugging time!

The 134-second timeout was another important lesson: check DNS, AAAA records, and routing before assuming the firewall is responsible.

And yes, binding the proxy specifically to the Tailscale IP instead of 0.0.0.0 was a deliberate security decision.

I really appreciate you taking the time to highlight these details. It's exactly the kind of technical discussion I hoped this article would encourage!