I discovered nport (https://github.com/tuanngocptn/nport) - a fantastic ngrok alternative built in Node.js. It’s free, open-source, and uses Cloudflare’s infrastructure. But I wanted something with: Smaller footprint - Single binary, no Node.js runtime Faster startup - Go’s compilation speed Better concurrency - Native goroutines Learning opportunity - Deep dive into tunneling tech Smaller footprint - Single binary, no Node.js runtime Smaller footprint Faster startup - Go’s compilation speed Faster startup Better concurrency - Native goroutines Better concurrency Learning opportunity - Deep dive into tunneling tech Learning opportunity So, I decided to build something myself. Starting with interrogating some of my core decisions along the way, I’ll walk you through what I built. Why Go? Why Go? Performance Comparison: Performance Comparison: Binary size Binary size Node.js (nport): ~50MB + Node.js runtime Go (golocalport): ~10MB standalone Node.js (nport): ~50MB + Node.js runtime Go (golocalport): ~10MB standalone Startup time Startup time Node.js (nport): ~500ms Go (golocalport): ~50ms Node.js (nport): ~500ms Go (golocalport): ~50ms Memory usage Memory usage Node.js (nport): ~30MB Go (golocalport): ~5MB Node.js (nport): ~30MB Go (golocalport): ~5MB Concurrency Concurrency Node.js (nport): Event loop Go (golocalport): Native goroutines Node.js (nport): Event loop Go (golocalport): Native goroutines Dependencies Dependencies Node.js (nport): Many npm packages Go (golocalport): Zero external (stdlib only) Node.js (nport): Many npm packages Go (golocalport): Zero external (stdlib only) Architecture Overview Architecture Overview The system is built with clean separation of concerns: Core Components: Core Components: CLI Interface - Flag parsing, user interaction API Client - Communicates with backend Binary Manager - Downloads/manages cloudflared Tunnel Orchestrator - Lifecycle management State Manager - Thread-safe runtime state UI Display - Pretty terminal output CLI Interface - Flag parsing, user interaction CLI Interface API Client - Communicates with backend API Client Binary Manager - Downloads/manages cloudflared Binary Manager Tunnel Orchestrator - Lifecycle management Tunnel Orchestrator State Manager - Thread-safe runtime state State Manager UI Display - Pretty terminal output UI Display Implementation Journey Implementation Journey Phase 1: Project Setup (15 minutes) Phase 1: Project Setup (15 minutes) Started with the basics: go mod init github.com/devshark/golocalport go mod init github.com/devshark/golocalport Created clean project structure: golocalport/ ├── cmd/golocalport/main.go # Entry point ├── internal/ │ ├── api/ # Backend client │ ├── binary/ # Cloudflared manager │ ├── config/ # Configuration │ ├── state/ # State management │ ├── tunnel/ # Orchestrator │ └── ui/ # Display └── server/ # Backend API golocalport/ ├── cmd/golocalport/main.go # Entry point ├── internal/ │ ├── api/ # Backend client │ ├── binary/ # Cloudflared manager │ ├── config/ # Configuration │ ├── state/ # State management │ ├── tunnel/ # Orchestrator │ └── ui/ # Display └── server/ # Backend API Phase 2: Core Infrastructure (30 minutes) Phase 2: Core Infrastructure (30 minutes) Config Package - Dead simple constants: Config Package const ( Version = "0.1.0" DefaultPort = 8080 DefaultBackend = "https://api.golocalport.link" TunnelTimeout = 4 * time.Hour ) const ( Version = "0.1.0" DefaultPort = 8080 DefaultBackend = "https://api.golocalport.link" TunnelTimeout = 4 * time.Hour ) State Manager - Thread-safe with mutex: State Manager type State struct { mu sync.RWMutex TunnelID string Subdomain string Port int Process *exec.Cmd StartTime time.Time } type State struct { mu sync.RWMutex TunnelID string Subdomain string Port int Process *exec.Cmd StartTime time.Time } Phase 3: API Client (20 minutes) Phase 3: API Client (20 minutes) Simple HTTP client for backend communication: func (c *Client) CreateTunnel(subdomain, backendURL string) (*CreateResponse, error) { body, _ := json.Marshal(map[string]string{"subdomain": subdomain}) resp, err := c.httpClient.Post(backendURL, "application/json", bytes.NewBuffer(body)) // ... handle response } func (c *Client) CreateTunnel(subdomain, backendURL string) (*CreateResponse, error) { body, _ := json.Marshal(map[string]string{"subdomain": subdomain}) resp, err := c.httpClient.Post(backendURL, "application/json", bytes.NewBuffer(body)) // ... handle response } Phase 4: Binary Manager (45 minutes) Phase 4: Binary Manager (45 minutes) Challenge: macOS cloudflared comes as .tgz, not raw binary. Challenge .tgz Solution: Detect file type and extract: Solution func Download(binPath string) error { url := getDownloadURL() resp, err := http.Get(url) // Handle .tgz files for macOS if filepath.Ext(url) == ".tgz" { return extractTgz(resp.Body, binPath) } // Direct binary for Linux/Windows // ... } func Download(binPath string) error { url := getDownloadURL() resp, err := http.Get(url) // Handle .tgz files for macOS if filepath.Ext(url) == ".tgz" { return extractTgz(resp.Body, binPath) } // Direct binary for Linux/Windows // ... } Cross-platform URL mapping: urls := map[string]string{ "darwin-amd64": baseURL + "/cloudflared-darwin-amd64.tgz", "darwin-arm64": baseURL + "/cloudflared-darwin-amd64.tgz", "linux-amd64": baseURL + "/cloudflared-linux-amd64", "windows-amd64": baseURL + "/cloudflared-windows-amd64.exe", } urls := map[string]string{ "darwin-amd64": baseURL + "/cloudflared-darwin-amd64.tgz", "darwin-arm64": baseURL + "/cloudflared-darwin-amd64.tgz", "linux-amd64": baseURL + "/cloudflared-linux-amd64", "windows-amd64": baseURL + "/cloudflared-windows-amd64.exe", } Phase 5: Tunnel Orchestrator (30 minutes) Phase 5: Tunnel Orchestrator (30 minutes) Coordinates everything: func Start(cfg *config.Config) error { // 1. Ensure binary exists if !binary.Exists(config.BinPath) { binary.Download(config.BinPath) } // 2. Create tunnel via API resp, err := client.CreateTunnel(cfg.Subdomain, cfg.BackendURL) // 3. Start cloudflared process cmd, err := binary.Spawn(config.BinPath, resp.TunnelToken, cfg.Port) // 4. Setup timeout & signal handling timer := time.AfterFunc(config.TunnelTimeout, Cleanup) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) <-sigChan } func Start(cfg *config.Config) error { // 1. Ensure binary exists if !binary.Exists(config.BinPath) { binary.Download(config.BinPath) } // 2. Create tunnel via API resp, err := client.CreateTunnel(cfg.Subdomain, cfg.BackendURL) // 3. Start cloudflared process cmd, err := binary.Spawn(config.BinPath, resp.TunnelToken, cfg.Port) // 4. Setup timeout & signal handling timer := time.AfterFunc(config.TunnelTimeout, Cleanup) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) <-sigChan } Phase 6: CLI Interface (15 minutes) Phase 6: CLI Interface (15 minutes) Standard library flag package - no dependencies needed: flag subdomain := flag.String("s", "", "Custom subdomain") backend := flag.String("b", "", "Backend URL") version := flag.Bool("v", false, "Show version") flag.Parse() port := config.DefaultPort if flag.NArg() > 0 { port, _ = strconv.Atoi(flag.Arg(0)) } subdomain := flag.String("s", "", "Custom subdomain") backend := flag.String("b", "", "Backend URL") version := flag.Bool("v", false, "Show version") flag.Parse() port := config.DefaultPort if flag.NArg() > 0 { port, _ = strconv.Atoi(flag.Arg(0)) } Phase 7: Backend Server (45 minutes) Phase 7: Backend Server (45 minutes) Built a minimal Go server instead of using Cloudflare Workers: Why? Why? Full control Easy to self-host No vendor lock-in Can run anywhere Full control Easy to self-host No vendor lock-in Can run anywhere Implementation: Implementation func handleCreate(w http.ResponseWriter, r *http.Request) { // 1. Create Cloudflare Tunnel tunnelID, token, err := createCloudflaredTunnel(subdomain) // 2. Create DNS CNAME record fullDomain := fmt.Sprintf("%s.%s", subdomain, cfDomain) cnameTarget := fmt.Sprintf("%s.cfargotunnel.com", tunnelID) createDNSRecord(fullDomain, cnameTarget) // 3. Return credentials json.NewEncoder(w).Encode(CreateResponse{ Success: true, TunnelID: tunnelID, TunnelToken: token, URL: fmt.Sprintf("https://%s", fullDomain), }) } func handleCreate(w http.ResponseWriter, r *http.Request) { // 1. Create Cloudflare Tunnel tunnelID, token, err := createCloudflaredTunnel(subdomain) // 2. Create DNS CNAME record fullDomain := fmt.Sprintf("%s.%s", subdomain, cfDomain) cnameTarget := fmt.Sprintf("%s.cfargotunnel.com", tunnelID) createDNSRecord(fullDomain, cnameTarget) // 3. Return credentials json.NewEncoder(w).Encode(CreateResponse{ Success: true, TunnelID: tunnelID, TunnelToken: token, URL: fmt.Sprintf("https://%s", fullDomain), }) } Cloudflare API integration (~100 lines): func cfRequest(method, url string, body interface{}) (json.RawMessage, error) { req, _ := http.NewRequest(method, url, reqBody) req.Header.Set("Authorization", "Bearer "+cfAPIToken) req.Header.Set("Content-Type", "application/json") // ... handle response } func cfRequest(method, url string, body interface{}) (json.RawMessage, error) { req, _ := http.NewRequest(method, url, reqBody) req.Header.Set("Authorization", "Bearer "+cfAPIToken) req.Header.Set("Content-Type", "application/json") // ... handle response } Final Stats Final Stats Client (GoLocalPort CLI) Client (GoLocalPort CLI) Files: 7 Go files Lines of Code: ~600 Dependencies: 0 external (stdlib only) Binary Size: ~8MB Build Time: ~2 seconds Files: 7 Go files Files Lines of Code: ~600 Lines of Code Dependencies: 0 external (stdlib only) Dependencies Binary Size: ~8MB Binary Size Build Time: ~2 seconds Build Time Server (Backend API) Server (Backend API) Files: 2 Go files Lines of Code: ~200 Dependencies: 0 external (stdlib only) Deployment: Fly.io, Railway, Docker, VPS Files: 2 Go files Files Lines of Code: ~200 Lines of Code Dependencies: 0 external (stdlib only) Dependencies Deployment: Fly.io, Railway, Docker, VPS Deployment Total Development Time Total Development Time Planning & Analysis: 30 minutes Client Implementation: 2 hours Server Implementation: 45 minutes Documentation: 30 minutes Total: ~3.5 hours Planning & Analysis: 30 minutes Client Implementation: 2 hours Server Implementation: 45 minutes Documentation: 30 minutes Total: ~3.5 hours Total How It Works How It Works The flow is straightforward: You run golocalport 3000 -s myapp GoLocalPort creates a Cloudflare Tunnel via the backend API DNS record is created: myapp.golocalport.link → Cloudflare Edge Cloudflared connects your localhost:3000 to Cloudflare Traffic flows through Cloudflare’s network to your machine On exit (Ctrl+C), tunnel and DNS are cleaned up Internet → Cloudflare Edge → Cloudflare Tunnel → Your localhost:3000 (https://myapp.golocalport.link) You run golocalport 3000 -s myapp You run golocalport 3000 -s myapp golocalport 3000 -s myapp GoLocalPort creates a Cloudflare Tunnel via the backend API GoLocalPort creates a Cloudflare Tunnel via the backend API DNS record is created: myapp.golocalport.link → Cloudflare Edge DNS record is created: myapp.golocalport.link → Cloudflare Edge myapp.golocalport.link Cloudflared connects your localhost:3000 to Cloudflare Cloudflared connects your localhost:3000 to Cloudflare Traffic flows through Cloudflare’s network to your machine Traffic flows through Cloudflare’s network to your machine On exit (Ctrl+C), tunnel and DNS are cleaned up Internet → Cloudflare Edge → Cloudflare Tunnel → Your localhost:3000 (https://myapp.golocalport.link) On exit (Ctrl+C), tunnel and DNS are cleaned up Internet → Cloudflare Edge → Cloudflare Tunnel → Your localhost:3000 (https://myapp.golocalport.link) Internet → Cloudflare Edge → Cloudflare Tunnel → Your localhost:3000 (https://myapp.golocalport.link) Usage Usage Client: Client: # Build go build -o golocalport cmd/golocalport/main.go # Run with random subdomain ./golocalport 3000 # Run with custom subdomain ./golocalport 3000 -s myapp # Creates: https://myapp.yourdomain.com # Build go build -o golocalport cmd/golocalport/main.go # Run with random subdomain ./golocalport 3000 # Run with custom subdomain ./golocalport 3000 -s myapp # Creates: https://myapp.yourdomain.com Server: Server: # Deploy to Fly.io (free) cd server fly launch fly secrets set CF_ACCOUNT_ID=xxx CF_ZONE_ID=xxx CF_API_TOKEN=xxx CF_DOMAIN=yourdomain.com fly deploy # Deploy to Fly.io (free) cd server fly launch fly secrets set CF_ACCOUNT_ID=xxx CF_ZONE_ID=xxx CF_API_TOKEN=xxx CF_DOMAIN=yourdomain.com fly deploy Key Learnings Key Learnings 1. Go’s Stdlib is Powerful 1. Go’s Stdlib is Powerful No external dependencies needed for: HTTP client/server JSON parsing Tar/gzip extraction Process management Signal handling HTTP client/server JSON parsing Tar/gzip extraction Process management Signal handling 2. Cloudflare Tunnels are Amazing 2. Cloudflare Tunnels are Amazing Free tier is generous Global edge network Automatic HTTPS No port forwarding needed Works behind NAT/firewalls Free tier is generous Global edge network Automatic HTTPS No port forwarding needed Works behind NAT/firewalls 3. Minimal Code is Better 3. Minimal Code is Better Easier to maintain Faster to understand Fewer bugs Better performance Easier to maintain Faster to understand Fewer bugs Better performance 4. Cross-Platform is Tricky 4. Cross-Platform is Tricky Different binary formats per OS: macOS: .tgz archive Linux: raw binary Windows: .exe macOS: .tgz archive .tgz Linux: raw binary Windows: .exe .exe Solution: Runtime detection + extraction logic Challenges & Solutions Challenges & Solutions Challenge 1: Binary Format Differences Challenge 1: Binary Format Differences Problem: macOS cloudflared is .tgz, not raw binary Solution: Detect extension, extract tar.gz on-the-fly Problem: macOS cloudflared is .tgz, not raw binary Problem .tgz Solution: Detect extension, extract tar.gz on-the-fly Solution Challenge 2: Thread Safety Challenge 2: Thread Safety Problem: Multiple goroutines accessing state Solution: sync.RWMutex for safe concurrent access Problem: Multiple goroutines accessing state Problem Solution: sync.RWMutex for safe concurrent access Solution sync.RWMutex Challenge 3: Graceful Shutdown Challenge 3: Graceful Shutdown Problem: Cleanup on Ctrl+C Solution: Signal handling + defer cleanup Problem: Cleanup on Ctrl+C Problem Solution: Signal handling + defer cleanup Solution Challenge 4: Backend Hosting Challenge 4: Backend Hosting Problem: Need somewhere to run backend Solution: Multiple options - Fly.io (free), Railway, Docker, VPS Problem: Need somewhere to run backend Problem Solution: Multiple options - Fly.io (free), Railway, Docker, VPS Solution What’s Next? What’s Next? Planned Features Planned Features Update checking Config file support Traffic inspection/logging Custom domains (not just subdomains) TUI interface Homebrew formula Update checking Config file support Traffic inspection/logging Custom domains (not just subdomains) TUI interface Homebrew formula Potential Improvements Potential Improvements Add tests (unit + integration) Performance benchmarks Windows/Linux testing Add tests (unit + integration) Performance benchmarks Windows/Linux testing nport vs golocalport nport vs golocalport Language Language nport: JavaScript golocalport: Go nport: JavaScript golocalport: Go Runtime Runtime nport: Node.js required golocalport: Standalone binary nport: Node.js required golocalport: Standalone binary Binary size Binary size nport: ~50MB + runtime golocalport: ~8MB nport: ~50MB + runtime golocalport: ~8MB Startup Startup nport: ~500ms golocalport: ~50ms nport: ~500ms golocalport: ~50ms Memory Memory nport: ~30MB golocalport: ~5MB nport: ~30MB golocalport: ~5MB Dependencies Dependencies nport: Many npm packages golocalport: Zero (stdlib) nport: Many npm packages golocalport: Zero (stdlib) Backend Backend nport: Cloudflare Worker golocalport: Go server (self-host) nport: Cloudflare Worker golocalport: Go server (self-host) Lines of code Lines of code nport: ~1000 golocalport: ~800 nport: ~1000 golocalport: ~800 Concurrency Concurrency nport: Event loop golocalport: Goroutines nport: Event loop golocalport: Goroutines Conclusion Conclusion Building GoLocalPort was a fantastic learning experience. In just a few hours, I created a production-ready tunnel service that: Works on macOS, Linux, Windows Has zero external dependencies Produces a tiny binary Starts instantly Uses minimal memory Includes both client and server Is fully open-source Works on macOS, Linux, Windows Has zero external dependencies Produces a tiny binary Starts instantly Uses minimal memory Includes both client and server Is fully open-source Go proved to be the perfect choice for this type of system tool. The standard library had everything needed, and the resulting binary is small, fast, and portable. Go proved to be the perfect choice Try It Yourself Try It Yourself # Clone the repo git clone https://github.com/devshark/golocalport.git cd golocalport # Build go build -o golocalport cmd/golocalport/main.go # Run ./golocalport 3000 # Clone the repo git clone https://github.com/devshark/golocalport.git cd golocalport # Build go build -o golocalport cmd/golocalport/main.go # Run ./golocalport 3000 Visit https://www.golocalport.link/ for installation instructions and documentation. Resources Resources Website: https://www.golocalport.link/ Source Code: https://github.com/devshark/golocalport Inspired by: https://github.com/tuanngocptn/nport Cloudflare Tunnels: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/ Website: https://www.golocalport.link/ Website Source Code: https://github.com/devshark/golocalport Source Code https://github.com/devshark/golocalport Inspired by: https://github.com/tuanngocptn/nport Inspired by https://github.com/tuanngocptn/nport Cloudflare Tunnels: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/ Cloudflare Tunnels https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/ Questions? Feedback? Open an issue on GitHub or reach out! Questions? Feedback? Made with ❤️ using Go