I Built a Go-Based ngrok Alternative With Zero Dependencies

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:

  1. CLI Interface – Flag parsing, user interaction
  2. API Client – Communicates with backend
  3. Binary Manager – Downloads/manages cloudflared
  4. Tunnel Orchestrator – Lifecycle management
  5. State Manager – Thread-safe runtime state
  6. 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:

  1. You run golocalport 3000 -s myapp

  2. GoLocalPort creates a Cloudflare Tunnel via the backend API

  3. DNS record is created: myapp.golocalport.link → Cloudflare Edge

  4. Cloudflared connects your localhost:3000 to Cloudflare

  5. Traffic flows through Cloudflare’s network to your machine

  6. 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: .tgz archive
  • 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.RWMutex for 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

Questions? Feedback? Open an issue on GitHub or reach out!

Made with ❤️ using Go

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.