Skip to content
asavpatelPublic

About

Pi-hole + unbound + WireGuard on a GCP free-tier VM. Ad blocking and a private recursive resolver that follow your devices anywhere. Terraform + stdlib Python CLI, no third-party DNS.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

9 Commits

Folders and files

Repository files navigation

skyhole

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.

What you get

  • 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 skyhole CLI 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.

How it fits together

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
Loading
  • 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.

Honest cost

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.

Security model

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.

Requirements

  • A GCP project with billing enabled
  • gcloud
  • Terraform 1.5+
  • Python 3.8+ (standard library only — no pip install step)
  • qrencode, to print scannable QR codes for phones: brew install qrencode or apt install qrencode
  • The official WireGuard app on each device

Quick start

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 it

In 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.

Commands

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.

Blocklists and allowlist

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 --sync

Declared 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.

Files you must not commit

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.

Layout

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

Development

No dependencies to install. make check runs terraform fmt/validate, syntax checks and the tests; CI runs the same.

Built on

License

MIT

About

Pi-hole + unbound + WireGuard on a GCP free-tier VM. Ad blocking and a private recursive resolver that follow your devices anywhere. Terraform + stdlib Python CLI, no third-party DNS.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages