Pi-hole + unbound + WireGuard on a Google Cloud free-tier VM, deployed entirely with Terraform and driven by an interactive CLI.
Network-wide ad and tracker blocking that follows your devices off your home network, with a recursive DNS resolver that talks to the root servers directly instead of handing your query history to a public resolver.
Sibling of groundhole, which runs the same stack on a Raspberry Pi in your own home. skyhole is for people who do not own a Pi, or whose home connection cannot accept an inbound WireGuard port (carrier-grade NAT makes port forwarding impossible no matter what you pay for): it needs no hardware and no reachable home IP. If you do have a Pi and a reachable connection, groundhole does the same job for free, and covers every device on your LAN rather than only the ones carrying a WireGuard profile.
- Pi-hole for blocking, with unbound behind it as a validating, DNSSEC-aware recursive resolver. No upstream resolver ever sees your queries.
- WireGuard as the only internet-facing service. DNS is never exposed to the internet, so the box can never be abused as an open resolver.
- Terraform for every piece of infrastructure — VPC, firewall, service account, IAM, instance, optional billing budget.
- An interactive
skyholeCLI that walks you through the choices, generates keys locally, and manages devices, blocklists and the allowlist.
What it is not: a VPN for hiding your traffic. By default only DNS crosses the tunnel; your browsing leaves your device the way it always did. Full-tunnel peers exist, but a single VM on your own GCP account is not anonymity.
flowchart LR
subgraph devices["Your devices (anywhere)"]
phone["Phone / laptop<br/>WireGuard app<br/>DNS = 10.8.0.1"]
end
subgraph gcp["GCP e2-micro VM (free tier)"]
wg["wg0<br/>10.8.0.1"]
nft["nftables<br/>default drop"]
pihole["Pi-hole<br/>blocklists, allowlist<br/>binds wg0 only"]
unbound["unbound<br/>127.0.0.1:5335<br/>recursive + DNSSEC"]
end
roots["Root & authoritative<br/>nameservers"]
web["Websites"]
ws["Your workstation<br/>skyhole CLI + Terraform"]
phone -- "DNS only, encrypted<br/>UDP 51820" --> wg
phone -- "everything else, direct" --> web
wg --> nft --> pihole --> unbound --> roots
ws -- "SSH over IAP<br/>(no public port)" --> gcp
- The only thing listening on the internet is WireGuard. Port scans see nothing.
- Devices send DNS through the tunnel and nothing else, so the 1 GB/month free egress is never in danger.
- Pi-hole answers or blocks; unbound resolves what is left by walking from the root servers itself. No Google, Cloudflare or ISP resolver is involved.
- You manage it from your machine with
skyhole. Keys are generated locally and pushed over an IAP SSH tunnel; nothing secret touches Terraform state.
The Always Free tier covers the compute:
| Resource | Free allowance | This project |
|---|---|---|
e2-micro VM |
1/month in us-west1, us-central1, us-east1 |
1 |
pd-standard disk |
30 GB-months | 30 GB |
| Egress from North America | 1 GB/month | see below |
| External IPv4 | none — no IPv4 address is free | ~USD 3–4/month |
Two things are worth being clear about:
External IPv4 costs money, and switching to an ephemeral address does not avoid it. Google bills every external IPv4 attached to a running VM at the same rate whether it is static or ephemeral — a fraction of a cent per hour, roughly USD 3–4/month; check the current rate on the network pricing page. The only difference shows up when the VM is stopped: a reserved static address keeps billing at a higher unattached rate, while an ephemeral address is released, costs nothing, and comes back as a different address, so every peer config has to be reissued.
IPv6 is genuinely free and is always enabled alongside, but an IPv6-only VM is not an option here: unbound resolves names by talking to authoritative nameservers directly, and many of those are still IPv4-only. Without an external IPv4 (or a Cloud NAT, which costs more than the address) recursion would fail for a large part of the internet. The address charge is therefore the floor, with a static IPv4 by default.
Full-tunnel VPN will exceed the free egress allowance. 1 GB/month is
roughly a day of ordinary browsing. That is why the default is a split
tunnel: AllowedIPs covers only the DNS server, so the only traffic crossing
GCP is DNS queries and the admin UI. That is a few tens of megabytes a month,
ad blocking works everywhere, and your browsing speed never touches the
shared-core VM. Full-tunnel peers are supported as an explicit opt-in.
| Decision | Why |
|---|---|
| Only UDP 51820 is open to the internet | WireGuard is silent to unauthenticated packets; a port scan sees nothing |
Pi-hole binds the wg0 interface only, and port 53 is denied at the VPC firewall |
An open recursive resolver is found and abused for DNS amplification within hours |
| SSH only through Identity-Aware Proxy | No public SSH port exists; access is an IAM decision, not a key file |
| OS Login enforced, project SSH keys blocked, serial console disabled | Removes the usual standing-credential paths onto the box |
| Shielded VM: secure boot, vTPM, integrity monitoring | Detects boot-level tampering |
Dedicated service account with only logging.logWriter and monitoring.metricWriter |
A compromised container cannot reach the rest of the project |
nftables default-drop on the host, in addition to the VPC firewall |
Second, independent fence around DNS and the admin UI |
| The container uses host networking; Docker is configured to write no firewall rules and touch no sysctls | One owner of the firewall. No bridge, NAT or forwarding path to misconfigure, and a firewall reload can never strip rules something else depended on |
| Per-peer pre-shared keys alongside the X25519 keys | Extra symmetric layer, and the usual hedge against future quantum attacks |
| Private keys never enter Terraform | terraform.tfstate stores everything in plaintext. Keys are generated locally and pushed over SSH, so state is safe to store in a bucket |
| Unattended security upgrades with a nightly reboot window | The box patches itself |
The most secure way to enrol a device is to generate its keypair on the device itself and register only the public half:
# on the device
umask 077 && wg genkey > laptop.key && wg pubkey < laptop.key
# on the workstation, with the printed public key
./skyhole peer add laptop --pubkey "<public key>"The private key then never exists anywhere but that device: paste it into the
PrivateKey placeholder of peers/laptop.conf there. When you omit
--pubkey, the CLI generates the keypair for you and stores it in peers/,
which is convenient and is what most people want for phones.
- A GCP project with billing enabled
gcloud- Terraform 1.5+
- Python 3.8+ (standard library only — no
pip installstep) qrencode, to print scannable QR codes for phones:brew install qrencodeorapt install qrencode- The official WireGuard app on each device
git clone https://github.com/asavpatel/skyhole && cd skyhole
gcloud auth login && gcloud auth application-default login
./skyhole init # interactive wizard
./skyhole deploy # terraform apply
./skyhole sync # push WireGuard + Pi-hole config over IAP
./skyhole peer add phone --sync # enrol a device and activate itIn the WireGuard app, add a tunnel from QR code and scan the one printed (the
phone's camera app will not do; only the WireGuard app understands it), or
import peers/phone.conf. Turn on "on-demand" so the tunnel stays up. The
Pi-hole admin UI is at http://10.8.0.1/admin while the tunnel is up; the
password is in secrets/pihole-admin-password or from ./skyhole password.
Devices added without --sync are not known to the server until the next
skyhole sync; until then the tunnel never handshakes and nothing resolves.
| Command | What it does |
|---|---|
skyhole init |
Interactive wizard: project, region, addressing, tunnel mode, blocklists, budget. Generates the server keypair locally and writes terraform.tfvars. |
skyhole deploy |
Enables the required APIs, runs terraform apply, records the endpoint address and rewrites the peer configs with it. |
skyhole sync |
Pushes wg0.conf, the Pi-hole env file and the declared lists over the IAP tunnel; re-runs the host bootstrap, reloads peers live, reconciles blocklists and allowlist. |
skyhole peer add NAME |
Enrols a device. --full-tunnel routes all of its traffic through GCP. --pubkey registers a device-held key. |
skyhole peer list / remove |
Show or revoke devices. |
skyhole blocklist add URL / remove / list |
Manage the adlists Pi-hole blocks from. |
skyhole allow add DOMAIN / remove / list |
Domains that must never be blocked (exact match). |
skyhole qr NAME |
Print a peer config as a QR code. |
skyhole status |
Handshakes, container health, service state, disk usage. |
skyhole logs [-f] |
Tail the Pi-hole container logs. |
skyhole ssh |
Shell on the VM through IAP. |
skyhole destroy |
Tear everything down, with a typed confirmation. |
Both live in skyhole.json and are applied by skyhole sync, which
reconciles Pi-hole to exactly what is declared and rebuilds gravity when a
list changed. The default is Pi-hole's own: StevenBlack's unified hosts.
./skyhole blocklist add https://big.oisd.nl --sync
./skyhole allow add s.youtube.com --syncDeclared state is the truth for these two tables. Adlists and exact allowed
domains added in the admin UI are removed on the next sync; deny rules and
regex rules are never touched, so the UI remains the place for those.
skyhole.json, secrets/, peers/ and *.tfvars are gitignored. They hold
the server private key, the Pi-hole admin password and every peer's key
material. Back up secrets/ somewhere safe: losing secrets/server.key means
re-enrolling every device.
terraform/ VPC, firewall, IAM, instance, budget, and the host bootstrap script
cli/ the skyhole CLI and the list reconciler it pushes to the VM (standard library only)
docs/ operations and troubleshooting notes
tests/ self-tests, run by `make check` and CI
No dependencies to install. make check runs terraform fmt/validate, syntax
checks and the tests; CI runs the same.
- pi-hole/pi-hole
- mpgirro/docker-pihole-unbound — the combined image this deploys
- unbound and WireGuard
MIT