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:

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

Startup time

Memory usage

Concurrency

Dependencies

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?

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)

Server (Backend API)

Total Development Time

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:

2. Cloudflare Tunnels are Amazing

3. Minimal Code is Better

4. Cross-Platform is Tricky

Different binary formats per OS:

Solution: Runtime detection + extraction logic

Challenges & Solutions

Challenge 1: Binary Format Differences

Challenge 2: Thread Safety

Challenge 3: Graceful Shutdown

Challenge 4: Backend Hosting

What’s Next?

Planned Features

Potential Improvements

nport vs golocalport

Language

Runtime

Binary size

Startup

Memory

Dependencies

Backend

Lines of code

Concurrency

Conclusion

Building GoLocalPort was a fantastic learning experience. In just a few hours, I created a production-ready tunnel service that:

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