The topic of self-hosting projects has been gaining popularity recently among IT specialists of all kinds. This is happening for many reasons, among which I would highlight the following:

  • Complete control over data. Due to the growing level of technical literacy, as well as facts of trading and frequent leaks of sensitive user data, data owners want to keep it closer to themselves.
  • Often, maintaining your own server in the long run will be cheaper than paying a cloud provider. This is especially true for projects requiring significant computational resources. Prices become quite indecent if you need, for example, a multi-core processor and a large amount of RAM. Any home server will pay for itself in a year or two.
  • The flexibility that an engineer gets with their own server. You are not limited in any way in choosing the operating system and application software.
  • The desire for horizontal development as an engineer and acquiring new skills inherent to a DevOps specialist.

If you've concluded that self-hosting is your choice, at some point you'll be creating DNS records for convenient access to services running on the server. Ideally, you'd have a static IP address for this, but sometimes it's not available. For example, in my case, it's because my internet provider offers such a service only to businesses.

If you've encountered such a limitation, one solution could be a lightweight Go program that will periodically obtain your current IP address, and if it has changed since the last request, update the configuration on the specified domain through the Cloudflare API.

Here, I'm assuming that you're using Cloudflare as a proxy when accessing your domain. Also, that the NS servers in your domain registrar's settings are configured to Cloudflare. If these requirements are met, you'll be able to use the program as is. If not, you'll probably be able to modify it to access your registrar's API, if available.

First of all, you'll need some data that you can find in the Cloudflare admin panel, namely:

  • API token
  • Zone id
  • DNS record id

All of this is needed to set the correct request headers, as well as to construct the URL of the endpoint to which this request will be sent.

Let's start with the API Token. To create it, you need to go to My Profile → API Tokens and click the Create Token button. Then select the pre-configured Edit Zone DNS template.

Next, in the rightmost dropdown of the Zone Resources section, you need to select the domain you're planning to work with and click Continue to summary, then on the next step, click Create token. All that's left is to copy the generated token.

Next up is the Zone id. To find it, you need to go to the Websites section and select the domain you'll be working with. After that, in the bottom right corner in the API section, you'll find the Zone id.

The last thing to find is the DNS record id. You won't be able to find it in the interface, but we can make a simple cURL request to obtain it. First, you need to create an A-type record and set it to any IP address, which will later be replaced by our program. Once the record is created, execute the following command in the terminal, after substituting the values obtained in the previous two steps:

curl --request GET \
  --url https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/dns_records \
  --header 'Authorization: Bearer {API_TOKEN}' \
  --header 'Content-Type: application/json'

The Cloudflare API will return a list of records associated with the specified domain zone, among which you need to find the one you created and take its id.

Now we have all the variables necessary for the program to work, and we can start writing the code.

Create a main.go file in the root directory of the Go module and add:

package main

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"time"

	"encoding/json"

	"github.com/joho/godotenv"
	"github.com/rdegges/go-ipify"
)

func main() {
	var ip string

	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}

	for {
		time.Sleep(time.Second)
		
		currentIP, err := getCurrentIp()
		if err != nil {
			log.Printf("Failed to get current IP: %s\n", err)
			continue
		}

		if currentIP == ip {
			continue
		}

		if err := updateDnsRecord(currentIP); err != nil {
			log.Printf("Failed to update DNS record: %s\n", err)
		} else {
			fmt.Printf("DNS record has been updated to: %s\n", currentIP)
			ip = currentIP
		}
	}
}

Essentially, this is an infinite loop that requests the current IP once per second, and if it has changed since the last time, it updates the DNS record value to the current one. Let's move on to the actual functions for obtaining the current IP address and updating it.

func getCurrentIp() (string, error) {
	ip, err := ipify.GetIp()
	if err != nil {
		return "", err
	}
	return ip, nil
}

Here, everything is elementary. Using the package github.com/rdegges/go-ipify, we call the GetIp method and return the obtained value.

Next, let's examine the updateDnsRecord function.

func updateDnsRecord(ip string) error {
	// Add the environment variables below to the .env file in the root of your project
	// We'll take this URL from the cURL request that was made earlier
	// https://api.cloudflare.com/client/v4
	apiURL := os.Getenv("CF_API_URL")
	apiToken := os.Getenv("CF_API_TOKEN")
	zoneId := os.Getenv("CF_ZONE_ID")
	recordId := os.Getenv("CF_DNS_RECORD_ID")

	// Declare a struct, which will be used to deserialize a server's response
	resp := struct {
		Success bool `json:"success"`
	}{}
	
	// Request's body that is sent to the Cloudflare API endpoint
	jsonBody := []byte(fmt.Sprintf(`{"content": "%s"}`, ip))
	bodyReader := bytes.NewReader(jsonBody)
	// Created an new request instance, set correct headers and the body
	req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/zones/%s/dns_records/%s", apiURL, zoneId, recordId), bodyReader)
	if err != nil {
		return err
	}
	
	req.Header.Set("Authorization", "Bearer "+apiToken)
	req.Header.Set("Content-Type", "application/json")

	// Execute the request and check that the server didn't return an error
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}

	if res.StatusCode != http.StatusOK {
		return fmt.Errorf("status code received %d", res.StatusCode)
	}
	
	if res.Body != nil {
		defer res.Body.Close()

		body, err := io.ReadAll(res.Body)
		if err != nil {
			return err
		}
		// Deserialize the response and check that the struct's Success field is true
		err = json.Unmarshal(body, &resp)
		if err != nil {
			return err
		}

		if !resp.Success {
			return fmt.Errorf("result not successful")
		}
	}

	return nil
}

This is all that's needed for the program to work. Now we just need to decide how to run it. To check its functionality locally, we can write a simple makefile, for example like this:

GOCMD = go
GOBUILD = $(GOCMD) build
GORUN = $(GOCMD) run
GOTEST = $(GOCMD) test
BINARY_NAME = cf-dns-update
MAIN_FILE = main.go

build:
	$(GOBUILD) -o /tmp/bin/$(BINARY_NAME) $(MAIN_FILE)

test:
	$(GOTEST) -v ./...

run:
	$(GORUN) $(MAIN_FILE)

As for running it in production, it's best to package it in a lightweight Docker container, of course. I think you won't have trouble finding a simple Dockerfile. And if you happen to use Coolify, like I do, you don't need to do anything at all, as it uses Nixpacks to build the container - just create a new project and specify the repository.

This is all I wanted to tell you in this article. You can find the full project code in the repository at https://github.com/prplx/go-cloudflare-dns-update. It also contains logic for trying to get the current IP-address using a backup option if an error is returned by the first service, so check it out.

See you next time!