DEV Community

Vix
Vix

Posted on

Getting the public IP in Go — no dependencies, no API key

Getting the public IP in Go — no dependencies, no API key

Go's standard library is famously self-contained, and IP detection is a case where that pays off directly. Everything in this article uses only the standard library plus one optional third-party HTTP client — no SDK, no configuration file, no API key required. The API is IPPubblico.org: free, HTTPS, plain text and JSON endpoints, no authentication.


Use case 1 — Your server's own public IP (one-liner)

The simplest case: a Go program or script that needs to know its own public IP.

package main

import (
    "fmt"
    "io"
    "net/http"
    "strings"
    "time"
)

func getPublicIP() (string, error) {
    client := &http.Client{Timeout: 10 * time.Second}
    resp, err := client.Get("https://ipv4.ippubblico.org/")
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }
    return strings.TrimSpace(string(body)), nil
}

func main() {
    ip, err := getPublicIP()
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Printf("Public IP: %s\n", ip) // Public IP: 203.0.113.42
}
Enter fullscreen mode Exit fullscreen mode

No imports beyond the standard library. The Timeout on the HTTP client prevents the program from hanging indefinitely if the network is unavailable.


Use case 2 — Full geolocation data

When you need country, city, ISP and ASN in addition to the IP address:

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "time"
)

type GeoData struct {
    Country     string  `json:"country"`
    CountryCode string  `json:"country_code"`
    City        string  `json:"city"`
    Region      string  `json:"region"`
    Lat         float64 `json:"lat"`
    Lon         float64 `json:"lon"`
}

type IPInfo struct {
    Status   string  `json:"status"`
    IP       string  `json:"ip"`
    IPv4     string  `json:"ipv4"`
    IPv6     *string `json:"ipv6"`
    ISP      string  `json:"isp"`
    ASN      string  `json:"asn"`
    Timezone string  `json:"timezone"`
    Geo      GeoData `json:"geo"`
}

func getIPInfo() (*IPInfo, error) {
    client := &http.Client{Timeout: 10 * time.Second}
    resp, err := client.Get("https://ippubblico.org/?api=1")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

    var info IPInfo
    if err := json.Unmarshal(body, &info); err != nil {
        return nil, err
    }
    if info.Status != "ok" {
        return nil, fmt.Errorf("API returned status: %s", info.Status)
    }
    return &info, nil
}

func main() {
    info, err := getIPInfo()
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Printf("IP:       %s\n", info.IP)
    fmt.Printf("Country:  %s (%s)\n", info.Geo.Country, info.Geo.CountryCode)
    fmt.Printf("City:     %s, %s\n", info.Geo.City, info.Geo.Region)
    fmt.Printf("ISP:      %s\n", info.ISP)
    fmt.Printf("Timezone: %s\n", info.Timezone)
}
Enter fullscreen mode Exit fullscreen mode

Use case 3 — Reusable client with caching

In a real application you rarely want to call the API on every request. This client caches the result for a configurable duration:

package ipdetect

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "sync"
    "time"
)

type Client struct {
    httpClient *http.Client
    cacheTTL   time.Duration

    mu        sync.Mutex
    cached    *IPInfo
    cachedAt  time.Time
}

type IPInfo struct {
    IP      string `json:"ip"`
    Country string `json:"country"`
    City    string `json:"city"`
    ISP     string `json:"isp"`
    ASN     string `json:"asn"`
}

func NewClient(cacheTTL time.Duration) *Client {
    return &Client{
        httpClient: &http.Client{Timeout: 10 * time.Second},
        cacheTTL:   cacheTTL,
    }
}

func (c *Client) Get() (*IPInfo, error) {
    c.mu.Lock()
    defer c.mu.Unlock()

    if c.cached != nil && time.Since(c.cachedAt) < c.cacheTTL {
        return c.cached, nil
    }

    resp, err := c.httpClient.Get("https://ippubblico.org/?api=1")
    if err != nil {
        return nil, fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("read body: %w", err)
    }

    var raw struct {
        Status string  `json:"status"`
        IP     string  `json:"ip"`
        ISP    string  `json:"isp"`
        ASN    string  `json:"asn"`
        Geo    struct {
            Country string `json:"country"`
            City    string `json:"city"`
        } `json:"geo"`
    }
    if err := json.Unmarshal(body, &raw); err != nil {
        return nil, fmt.Errorf("parse JSON: %w", err)
    }

    info := &IPInfo{
        IP:      raw.IP,
        ISP:     raw.ISP,
        ASN:     raw.ASN,
        Country: raw.Geo.Country,
        City:    raw.Geo.City,
    }
    c.cached = info
    c.cachedAt = time.Now()
    return info, nil
}
Enter fullscreen mode Exit fullscreen mode

Usage:

client := ipdetect.NewClient(1 * time.Hour)

info, err := client.Get() // calls API
info, err  = client.Get() // returns cached result
Enter fullscreen mode Exit fullscreen mode

Use case 4 — DDNS updater

A production-ready DDNS updater that detects IP changes and updates a Cloudflare DNS record:

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strings"
    "time"
)

const (
    cfAPIToken  = "your_cloudflare_api_token"
    cfZoneID    = "your_zone_id"
    cfRecordID  = "your_record_id"
    recordName  = "home.yourdomain.com"
    cacheFile   = "/tmp/ddns_last_ip.txt"
    checkEvery  = 5 * time.Minute
)

var httpClient = &http.Client{Timeout: 10 * time.Second}

func getPublicIP() (string, error) {
    resp, err := httpClient.Get("https://ipv4.ippubblico.org/")
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    return strings.TrimSpace(string(body)), nil
}

func getLastKnownIP() string {
    data, err := os.ReadFile(cacheFile)
    if err != nil {
        return ""
    }
    return strings.TrimSpace(string(data))
}

func saveIP(ip string) {
    os.WriteFile(cacheFile, []byte(ip), 0644)
}

func updateCloudflare(ip string) error {
    url := fmt.Sprintf(
        "https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s",
        cfZoneID, cfRecordID,
    )
    payload := map[string]interface{}{
        "type":    "A",
        "name":    recordName,
        "content": ip,
        "ttl":     60,
    }
    body, _ := json.Marshal(payload)

    req, _ := http.NewRequest("PUT", url, bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+cfAPIToken)
    req.Header.Set("Content-Type", "application/json")

    resp, err := httpClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    var result map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&result)
    if result["success"] != true {
        return fmt.Errorf("cloudflare error: %v", result["errors"])
    }
    return nil
}

func checkAndUpdate() {
    current, err := getPublicIP()
    if err != nil {
        log.Printf("Failed to get IP: %v", err)
        return
    }

    last := getLastKnownIP()
    if current == last {
        log.Printf("IP unchanged: %s", current)
        return
    }

    log.Printf("IP changed: %s → %s", last, current)
    if err := updateCloudflare(current); err != nil {
        log.Printf("DNS update failed: %v", err)
        return
    }
    saveIP(current)
    log.Printf("DNS updated to %s", current)
}

func main() {
    log.Println("DDNS updater started")
    checkAndUpdate()
    for range time.Tick(checkEvery) {
        checkAndUpdate()
    }
}
Enter fullscreen mode Exit fullscreen mode

Build as a single static binary — no runtime dependencies:

go build -o ddns_updater .
# or cross-compile for Linux ARM (e.g. Raspberry Pi)
GOOS=linux GOARCH=arm64 go build -o ddns_updater_arm64 .
Enter fullscreen mode Exit fullscreen mode

Use case 5 — HTTP middleware for Gin

Detect the client's country and attach it to the Gin context for use in any handler:

package middleware

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "strings"
    "sync"
    "time"

    "github.com/gin-gonic/gin"
)

var (
    geoCache   = make(map[string]string)
    geoCacheMu sync.RWMutex
    httpClient = &http.Client{Timeout: 3 * time.Second}
)

func getCountry(ip string) string {
    geoCacheMu.RLock()
    if c, ok := geoCache[ip]; ok {
        geoCacheMu.RUnlock()
        return c
    }
    geoCacheMu.RUnlock()

    resp, err := httpClient.Get("https://ippubblico.org/?api=1")
    if err != nil {
        return ""
    }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)

    var data struct {
        Geo struct {
            CountryCode string `json:"country_code"`
        } `json:"geo"`
    }
    json.Unmarshal(body, &data)

    geoCacheMu.Lock()
    geoCache[ip] = data.Geo.CountryCode
    geoCacheMu.Unlock()

    return data.Geo.CountryCode
}

func GeoMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        ip := c.ClientIP()
        country := getCountry(ip)
        c.Set("country_code", country)
        c.Next()
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage in any Gin handler:

r := gin.Default()
r.Use(middleware.GeoMiddleware())

r.GET("/content", func(c *gin.Context) {
    country, _ := c.Get("country_code")
    c.JSON(200, gin.H{
        "country": country,
        "content": "Hello from " + fmt.Sprint(country),
    })
})
Enter fullscreen mode Exit fullscreen mode

Use case 6 — IPv4 and IPv6 detection

package main

import (
    "bufio"
    "fmt"
    "net/http"
    "strings"
    "time"
)

type DualStackIP struct {
    IPv4 string
    IPv6 string
}

func getBothIPs() (*DualStackIP, error) {
    client := &http.Client{Timeout: 10 * time.Second}
    resp, err := client.Get("https://ippubblico.org/?text=1")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    result := &DualStackIP{}
    scanner := bufio.NewScanner(resp.Body)
    for scanner.Scan() {
        line := scanner.Text()
        if strings.HasPrefix(line, "IPv4: ") {
            v := strings.TrimPrefix(line, "IPv4: ")
            if v != "NONE" {
                result.IPv4 = v
            }
        } else if strings.HasPrefix(line, "IPv6: ") {
            v := strings.TrimPrefix(line, "IPv6: ")
            if v != "NONE" {
                result.IPv6 = v
            }
        }
    }
    return result, scanner.Err()
}

func main() {
    ips, err := getBothIPs()
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Printf("IPv4: %s\n", ips.IPv4) // IPv4: 203.0.113.42
    fmt.Printf("IPv6: %s\n", ips.IPv6) // IPv6:  (empty if unavailable)
}
Enter fullscreen mode Exit fullscreen mode

Handling rate limits

The API returns 429 Too Many Requests with a Retry-After header if the same IP sends requests too frequently. A robust wrapper with retry logic:

func getPublicIPWithRetry(maxRetries int) (string, error) {
    client := &http.Client{Timeout: 10 * time.Second}

    for attempt := 0; attempt <= maxRetries; attempt++ {
        resp, err := client.Get("https://ipv4.ippubblico.org/")
        if err != nil {
            return "", err
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            retryAfter := resp.Header.Get("Retry-After")
            wait := 20 * time.Second
            if secs, err := time.ParseDuration(retryAfter + "s"); err == nil {
                wait = secs
            }
            resp.Body.Close()
            if attempt < maxRetries {
                time.Sleep(wait)
                continue
            }
            return "", fmt.Errorf("rate limited after %d attempts", maxRetries)
        }

        body, err := io.ReadAll(resp.Body)
        resp.Body.Close()
        if err != nil {
            return "", err
        }
        return strings.TrimSpace(string(body)), nil
    }
    return "", fmt.Errorf("max retries exceeded")
}
Enter fullscreen mode Exit fullscreen mode

Quick reference

Need Endpoint Response
IPv4 only https://ipv4.ippubblico.org/ 203.0.113.42
IPv6 only https://ipv6.ippubblico.org/ 2001:db8::1 or NONE
Both protocols https://ippubblico.org/?text=1 IPv4: x\nIPv6: x
Full geolocation https://ippubblico.org/?api=1 JSON with country, city, ISP

Full API documentation: ippubblico.org/docs.html


Conclusion

Go's standard library handles all the HTTP and JSON work cleanly — the code you write for IP detection in Go is shorter and more explicit than most other languages, with no hidden magic. The DDNS updater compiles to a single static binary that you can drop on any Linux server or embedded device without installing a runtime.

The caching client and Gin middleware patterns shown here are production-ready starting points — they handle concurrent access correctly with sync.RWMutex and fail gracefully when the API is unavailable.


Building something with Go and IPPubblico? Share your use case in the comments.

Top comments (0)