
TL;DR Built a complete ngrok-like tunnel service in Go in one evening (~3.5 hours of...
Built a complete ngrok-like tunnel service in Go in one evening (~3.5 hours of focused coding time). Includes both client CLI and backend server. Total code: ~800 lines. Works with Cloudflare Tunnels for free, secure HTTPS tunnels from localhost to the internet.
Tech Stack: Go, Cloudflare Tunnels, Cloudflare API
Website: golocalport.link
I discovered nport - a fantastic ngrok alternative built in Node.js. It's free, open-source, and uses Cloudflare's infrastructure. But I wanted something with:
| Feature | Node.js (nport) | Go (golocalport) |
|---|---|---|
| Binary size | ~50MB + Node.js | ~10MB standalone |
| Startup time | ~500ms | ~50ms |
| Memory usage | ~30MB | ~5MB |
| Concurrency | Event loop | Native goroutines |
| Dependencies | npm packages | Minimal stdlib |
┌─────────────┐
│ CLI │ Parse args, display UI
└──────┬──────┘
│
┌──────▼──────┐
│ Orchestrator│ Coordinate tunnel lifecycle
└──────┬──────┘
│
┌───┴────┬──────────┬─────────┐
│ │ │ │
┌──▼───┐ ┌──▼───┐ ┌────▼────┐ ┌──▼──┐
│ API │ │Binary│ │ State │ │ UI │
│Client│ │ Mgr │ │ Manager │ │ │
└──────┘ └──────┘ └─────────┘ └─────┘
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
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
}
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
}
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",
}
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
}
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))
}
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
}
┌───────────┐ ┌──────────────┐
│GoLocalPort│ ──1. Create Tunnel Request──────> │ Backend │
│ CLI │ │ Server │
└────┬──────┘ └──────┬───────┘
│ │
│ ├─2. Create CF Tunnel
│ │
│ ├─3. Create DNS Record
│ │
│ <──4. Return Token & URL─────────────────────── │
│
├─5. Download cloudflared (if needed)
│
├─6. Spawn cloudflared process
│
└─7. Tunnel Active! ✨
Internet ──> Cloudflare Edge ──> CF Tunnel ──> localhost:3000
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
No external dependencies needed for:
Different binary formats per OS:
.tgz archive.exeSolution: Runtime detection + extraction logic
Problem: macOS cloudflared is .tgz, not raw binary
Solution: Detect extension, extract tar.gz on-the-fly
Problem: Multiple goroutines accessing state
Solution: sync.RWMutex for safe concurrent access
Problem: Cleanup on Ctrl+C
Solution: Signal handling + defer cleanup
Problem: Need somewhere to run backend
Solution: Multiple options - Fly.io (free), Railway, Docker, VPS
| Feature | nport (Node.js) | golocalport (Go) |
|---|---|---|
| Language | JavaScript | Go |
| Runtime | Node.js required | Standalone binary |
| Binary size | ~50MB + runtime | ~8MB |
| Startup | ~500ms | ~50ms |
| Memory | ~30MB | ~5MB |
| Dependencies | Many npm packages | Zero (stdlib) |
| Backend | Cloudflare Worker | Go server (self-host) |
| Lines of code | ~1000 | ~800 |
| Concurrency | Event loop | Goroutines |
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.
# 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
Or visit golocalport.link for installation instructions and documentation.
Questions? Feedback? Open an issue on GitHub or reach out!
Visit golocalport.link to get started.
Made with ❤️ using Go
gemmaI ported the whole Gemma-4 family — E2B, E4B, 12B, 31B, and the 26B-A4B MoE — to run on...
communityHey DEV, I'm Tobore. Let's actually connect. I've been on here for a while now, mostly writing and...
ai(yep, kinda clickbait, just for the funsies 😊) At the beginning of the year, I relaunched my...
aiMy laptop was sitting idle with the fan at full tilt. Nothing was running that I knew of. The culprit...
githubactionsI Built a Thing! TL;DR — Google Gemini-based Pull Request reviews and Issue Triaging for...
aiI've been hearing the word "harness" thrown around a lot lately. I assumed it just meant "the IDE" or...
Workflows from the Neura Market marketplace related to this DeepSeek resource