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
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?
Performance Comparison:
Binary size
- Node.js (nport): ~50MB + Node.js runtime
- Go (golocalport): ~10MB standalone
Startup time
- Node.js (nport): ~500ms
- Go (golocalport): ~50ms
Memory usage
- Node.js (nport): ~30MB
- Go (golocalport): ~5MB
Concurrency
- Node.js (nport): Event loop
- Go (golocalport): Native goroutines
Dependencies
- Node.js (nport): Many npm packages
- Go (golocalport): Zero external (stdlib only)
Architecture Overview
The system is built with clean separation of concerns:
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
Implementation Journey
Phase 1: Project Setup (15 minutes)
Started with the basics:
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
Phase 2: Core Infrastructure (30 minutes)
Config Package - Dead simple constants:
const (
Version = "0.1.0"
DefaultPort = 8080
DefaultBackend = "https://api.golocalport.link"
TunnelTimeout = 4 * time.Hour
)
State Manager - Thread-safe with mutex:
type State struct {
mu sync.RWMutex
TunnelID string
Subdomain string
Port int
Process *exec.Cmd
StartTime time.Time
}
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
}
Phase 4: Binary Manager (45 minutes)
Challenge: macOS cloudflared comes as .tgz, not raw binary.
Solution: Detect file type and extract:
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",
}
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
}
Phase 6: CLI Interface (15 minutes)
Standard library flag package - no dependencies needed:
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)
Built a minimal Go server instead of using Cloudflare Workers:
Why?
- Full control
- Easy to self-host
- No vendor lock-in
- Can run anywhere
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),
})
}
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
}
Final Stats
Client (GoLocalPort CLI)
- Files: 7 Go files
- Lines of Code: ~600
- Dependencies: 0 external (stdlib only)
- Binary Size: ~8MB
- Build Time: ~2 seconds
Server (Backend API)
- Files: 2 Go files
- Lines of Code: ~200
- Dependencies: 0 external (stdlib only)
- Deployment: Fly.io, Railway, Docker, VPS
Total Development Time
- Planning & Analysis: 30 minutes
- Client Implementation: 2 hours
- Server Implementation: 45 minutes
- Documentation: 30 minutes
- Total: ~3.5 hours
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)
Usage
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
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
Key Learnings
1. Go’s Stdlib is Powerful
No external dependencies needed for:
- HTTP client/server
- JSON parsing
- Tar/gzip extraction
- Process management
- Signal handling
2. Cloudflare Tunnels are Amazing
- Free tier is generous
- Global edge network
- Automatic HTTPS
- No port forwarding needed
- Works behind NAT/firewalls
3. Minimal Code is Better
- Easier to maintain
- Faster to understand
- Fewer bugs
- Better performance
4. Cross-Platform is Tricky
Different binary formats per OS:
- macOS:
.tgzarchive - Linux: raw binary
- Windows:
.exe
Solution: Runtime detection + extraction logic
Challenges & Solutions
Challenge 1: Binary Format Differences
- Problem: macOS cloudflared is
.tgz, not raw binary - Solution: Detect extension, extract tar.gz on-the-fly
Challenge 2: Thread Safety
- Problem: Multiple goroutines accessing state
- Solution:
sync.RWMutexfor safe concurrent access
Challenge 3: Graceful Shutdown
- Problem: Cleanup on Ctrl+C
- Solution: Signal handling + defer cleanup
Challenge 4: Backend Hosting
- Problem: Need somewhere to run backend
- Solution: Multiple options - Fly.io (free), Railway, Docker, VPS
What’s Next?
Planned Features
- Update checking
- Config file support
- Traffic inspection/logging
- Custom domains (not just subdomains)
- TUI interface
- Homebrew formula
Potential Improvements
- Add tests (unit + integration)
- Performance benchmarks
- Windows/Linux testing
nport vs golocalport
Language
- nport: JavaScript
- golocalport: Go
Runtime
- nport: Node.js required
- golocalport: Standalone binary
Binary size
- nport: ~50MB + runtime
- golocalport: ~8MB
Startup
- nport: ~500ms
- golocalport: ~50ms
Memory
- nport: ~30MB
- golocalport: ~5MB
Dependencies
- nport: Many npm packages
- golocalport: Zero (stdlib)
Backend
- nport: Cloudflare Worker
- golocalport: Go server (self-host)
Lines of code
- nport: ~1000
- golocalport: ~800
Concurrency
- nport: Event loop
- golocalport: Goroutines
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
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.
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
Visit https://www.golocalport.link/ for installation instructions and documentation.
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/
Questions? Feedback? Open an issue on GitHub or reach out!
Made with ❤️ using Go
