[home] [github] [portfolio] [reach out]

Homelabs I: tsnet feels like cheating

Posted on: 2025-12-26
#tailscale#golang#self-hosting#postgres#cloud-run

I: Context

I have been using the Asus TUF FX505GT since early 2020, and it got me through college, from coding to games and everything in between, so when I got myself a new PC, I knew I couldn’t just sunset the laptop this easily; Partly for sentimental reasons, partly because I don’t actually have a spare machine lying around yet (what if I travel?)

Anyways, I pulled out its larger SSD for my PC, dropped in a brand new smaller one, installed Ubuntu, and boom, server! just a playground though. We gotta do something with it.

There are honestly a lot of directions you can take a device of this nature now, and there are virtually no limits whatsoever, after all, the i5-9300H paired with 16GB RAM sounds fairly powerful for small use cases right? perhaps:

In practice though, there is one limit that shows up immediately - how do you reach it, incase you want to operate outside your home? Having god like hardware doesn’t matter here if you can’t reach it right?

Why does this happen?

CGNAT, in most cases or otherwise known as Carrier-Grade Network Address Translation.

I’m not an expert in all of this by any chance but, traditionally, your home router gets a public IP address, and everything inside your network sits behind it. With some port forwarding and firewall rules, you can expose a service to the internet. with CGNAT however:

I’m not really sure exactly why they use CGNAT but surely it couldnt be just a way to conserve IPV4 address, because if we all suddenly started using IPv6, I still don’t see ISPs giving a static IP for free. perhaps there is security risk among the list of reasons but oh well. who knows.

What did I chose?

Well I do have a golang backend running on GCP serving my other site. it runs fine, and by design, it talks to 3-4 external APIs to help populate my site. Wouldn’t it be nice if I could track latencies? but surely, a database isnt needed for this, heck I could just fmt.Logf(""), read the logs on Cloud Run console and call it day. But no, I really wanted to get my hands on PostgreSQL and NOT use any managed services for the same.

Well I just found my use case: a database server (sigh), and so began my research. I’m in no way stranger to databases, linux or even networking in general, but I really wanted a way for GCP to talk to my machine without too much effort. (Although it would have been a very nice learning experience)

II: VPNs and Tailscale

How VPNs might help here

Well thoughts can start from:

“maybe VPNs could help”

to:

“How do I set up one, so that it runs on a server at my home without my interaction at all but is secure as well? Although tempting, it seems too much work”

and finally land at:

“Isnt Cloud run a severless service with no easy exposure to the filesystem and hardware devices like NICs? how will I configure the VPN in cloud run?”

For homelabs, a remote-access VPN (or a peer-to-peer mesh VPN) is often the simplest, safest way to let cloud services or mobile devices reach private infrastructure without punching holes in your router - but this falls apart in situations like this, until you stumble across tailscale. an absolute gamesaver here.

About tailscale

While researching on places like r/HomeNetworking and r/selfhosted and even Gemini, I frequently saw mentions of “tailscale” by humans and bots, and how great it is. Naturally I got curious. Went to the website and was sold. It at least deserved a go. Installation wise, its dead simple, just follow the guide here. In a nutshell though, its just

curl -fsSL https://tailscale.com/install.sh | sh

followed by

sudo tailscale up

Thats it. follow the on screen prompts and you’re set. Cool, a VPN, I thought. All of this was nice and easy for personal access but now what? This doesnt solve the serverless problem?

Turns out it actually does.

userspace networking

I for some reason subconsciously assumed that networking is an OS thing - never gave it a second thought: Packets come in through a NIC, the kernel handles TCP/IP, sockets get exposed to applications, and that’s that. If you want to do “real” networking, you need access to the machine, the interfaces, and usually some level of privilege.

But thing is, TCP/IP doesn’t actually have to live in the kernel. Thats the idea of userspace networking. This works because, think about it, TCP/IP is just logic. It’s traditionally implemented in the kernel for performance and shared access. That means if you’re willing to pay a small overhead, you can implement the entire networking stack in userspace, inside your application process, decoupling you from the OS when it comes to networks.

tsnet

tsnet is a goated go library maintained by Tailscale that uses the idea above and hence lets you embed an entire Tailscale node inside your Go binary.

Once the binary starts, it authenticates with Tailscale, joins the tailnet, gets its own identity, and suddenly shows up in the admin console like any other device. Except this “device” happens to be a Cloud Run instance that didn’t exist a few seconds ago. It’s very neat.

Make the authentication credentials as Ephermal, and once the cloud service winds down, the device will get auto removed after a while, for small, hobby projects, this setup helps keep the limits in check.

We will use this.

III: Bringing Everything Together

We can start actually doing stuff now.

the database server

This blog is NOT a full follow-along tutorial, and configuring postgres is as standard as it gets. just follow this and call it a day. The neat part is how we reach this, rather than the database itself. Since we didnt install Ubuntu server on it, you can also go ahead and install pgAdmin

After installing it can, not should look like:

A possible approach to utilise tsnet

Before writing any code, I had a few non-negotiable requirements:

The shape of the code becomes much more obvious once you accept these constraints, we will quickly go through each parts.

a. Making the Database Optional

Instead of returning a *sql.DB directly, I wrap it in a small struct with a mutex and allow it to be set later:

type DBConnection struct {
	db *sql.DB
	mu sync.RWMutex
}

func (h *DBConnection) SetDB(database *sql.DB) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.db = database
}

func (h *DBConnection) GetDB() *sql.DB {
	h.mu.RLock()
	defer h.mu.RUnlock()
	return h.db
}

This lets the rest of the application ask, “Is there a database right now?” without assuming the answer is always yes.

If it’s nil, we simply don’t write to it.

b. Non-Blocking Startup

func InitDb(cfg *Config) *DBConnection {
	conn := &DBConnection{}

	if cfg.Database.Host == "" {
		log.Println("[INIT] skipping database..")
		return conn
	}

	go func() {
		// database initialization lives here
	}()

	return conn
}

From Cloud Run’s perspective, the service starts instantly. Whether the database connects in 2 seconds, 20 seconds, or never at all is irrelevant to request handling.

c. Calling Tailscale Only When Needed

Instead of hard-coding tsnet, the decision to use it is driven entirely by configuration:

if cfg.TailscaleAuthKey != "" {
	log.Printf("[INIT] need tsnet for host: %s\n", cfg.Database.Host)
	// tsnet setup lives here
}

d. Custom Dialer

Instead of asking Postgres to connect using the OS network stack, I override the dialer and route connections through the embedded Tailscale node:

pgxConfig.DialFunc = func(ctx context.Context, network, addr string) (net.Conn, error) {
	return s.Dial(ctx, network, addr)
}

At this point its a normal outbound connection from the service, which is essentially modified by tsnet to look like one. in reality, we are connected to the tail-net, which means we can reach our database.

e. Back-off strategy

Instead of crashing or blocking forever, I use a simple backoff loop:

for {
	err := db.PingContext(ctx)
	if err == nil {
		conn.SetDB(db)
		return
	}

	time.Sleep(backoff)
}

This is not robust though, just enough. I will be honest, we can engineer it better, but for now, we move on.

seeing it in action

As you can see it finally connected after a bit of work (ALL outbound are billed, so this isnt ideal actually), the reason you see 3x the logs is because Cloud Run spun up 3 instances probably because i reloaded very fast:

moreover, while it set itself up in the tailnet, the app also showed up in the console as a device:

refactoring the code to support logging

Originally, I wrote all external APIs in the form of a Service, with a client struct associated with it. Consider this for an example:

type Client struct {
	config config.GitHubConfig <- has secrets/vars/tokens
	http   *http.Client 
}

this struct can have methods attached to it that contains the business logic of fetching, transforming, if necessary.

since we inject a http client to every service, we can create a custom http.Client with custom logic on top of making the requests, that means, I can write a logging logic there, which uses the database, without touching individual call sites.

Go already gives you the perfect hook for this -> http.RoundTripper.

we can define:

type MetricTransport struct {
	Base    http.RoundTripper
	DB      *config.DBConnection
	Service string
}

then,

func (t *MetricTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	start := time.Now()

	db := t.DB.GetDB() // we either have it or we don't

	// Default to the standard transport if none is provided
	base := t.Base
	if base == nil {
		base = http.DefaultTransport
	}

	resp, err := base.RoundTrip(req)
	duration := time.Since(start)

	// If there's no database, we stop here
	if db == nil {
		log.Printf(
			"[METRICS] metrics disabled or still connecting :: %s took %v",
			t.Service,
			duration,
		)
		return resp, err
	}

the actual DB write can occur in a goroutine. A few important things to note here:

go func() {
		statusCode := 0
		if resp != nil {
			statusCode = resp.StatusCode
		}

		db.Exec() // your insert code here, standard.

		if dbErr != nil {
			log.Printf("[METRICS] DB Log Error: %v\n", dbErr)
		}
	}()

Now that we have a setup, we can have something like:

githubHttp := &http.Client{
	Timeout: 10 * time.Second,
	Transport: &telemetry.MetricTransport{
		DB:      db,
		Service: "github",
		Base:    http.DefaultTransport,
	},
}

The GitHub client itself has no idea that metrics exist. Repeat this for every service.

What I like about this approach

notice that it didn’t require:

Which is exactly what I wanted.

IV: Closing thoughts

With that, we have successfully self hosted a database, along with good programming patterns throughout a golang application. Thoughts-wise I have nothing to say except that self hosting is fun, even if there is no real use case or you break best practices sometimes here and there.

Hopefully it was an insightful read for you!


A small disclaimer: if you are reading this, and want to comment on anything, you'd have to reach out directly. this platform is still in its infancy and im yet to add all the "social" features; I also highly doubt my content would be engaging enough anyway to spark engagement but you never know when people want to discuss something, or, .... hate lol