All articles
12 September 2026
8 min read

Configuration and secrets for Go services

By Tim Rutte, Cloud & Software ArchitectTopicBackend & Platforms

Two white envelopes stacked, the top one closed with a blue wax seal.

The first Go service next to a PHP monolith almost always inherits its configuration model: a .env on the server, read at start. That works, and it brings two properties that are more unpleasant in a service than in a PHP application.

First, a missing value only surfaces when the code needs it, and that can be hours after the start. Second, the database password sits in a file distributed with the deployment, and changing it requires a deployment.

This article describes how to separate configuration and secrets in a Go service without the PHP side noticing anything.

Three layers, three origins

The starting point is a distinction that does not exist in a .env and that orders everything else.

Defaults live in the code. Timeouts, connection pool sizes, paths. They are deliberately chosen, they belong to the program, and they only get overridden in exceptional cases. A default in the code is better than one in a file, because it gets reviewed together with the code.

Environment values distinguish the environments: addresses, queue names, features that are switched on. They come from environment variables, they are visible, and that is exactly right: anybody wanting to know what a service is currently working against should be able to see it without asking.

Secrets are anything you can log in somewhere with. They come neither from the code nor from environment variables, they get fetched at start.

The separation between the last two is where something changes. Environment variables are not a secret: they appear in the task definition, they show up in error output, and anybody looking at the process reads them.

Check at start and fail

The first property I want in every service: if a value is missing or nonsensical, the service does not start. Not later, immediately, with a message containing the name of the value.

type Config struct {
	DatabaseAddr string
	LegacyURL    string
	Workers      int
	Timeout      time.Duration
}

func Load() (Config, error) {
	c := Config{
		// Defaults in the code. Anybody wanting to change them sets a
		// variable; anybody who does not still gets a justified value.
		Workers: 8,
		Timeout: 800 * time.Millisecond,
	}

	var missing []string
	required := func(name string) string {
		value := os.Getenv(name)
		if value == "" {
			missing = append(missing, name)
		}
		return value
	}

	c.DatabaseAddr = required("DB_ADDR")
	c.LegacyURL = required("LEGACY_URL")

	if n := os.Getenv("WORKERS"); n != "" {
		v, err := strconv.Atoi(n)
		if err != nil || v < 1 {
			return c, fmt.Errorf("WORKERS must be a number from 1 upwards, is %q", n)
		}
		c.Workers = v
	}

	// Report everything missing at once, not one at a time. Otherwise
	// somebody starts five times and gets five new surprises.
	if len(missing) > 0 {
		return c, fmt.Errorf("required values missing: %s", strings.Join(missing, ", "))
	}
	return c, nil
}

The comment at the end describes the difference that matters in practice. A start that aborts on the first missing value produces a series of attempts. One that collects them all produces a list, and somebody can work through that in one go.

Alongside it goes a second habit: the configuration gets loaded once at start and passed around as a value afterwards. An os.Getenv in the middle of the code is a dependency that appears in no test and is invisible when reading.

Secrets do not come from the environment

The same sequence applies to secrets, with a different source. The service does not receive a value, it receives a reference to one and fetches it itself at start.

// What the service receives as an environment variable is the reference:
//   DB_SECRET=arn:aws:secretsmanager:eu-central-1:...:secret/db-prod
//
// The value itself appears in no task definition, no log and no error
// output.
func fetchSecret(ctx context.Context, ref string) (Credentials, error) {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	out, err := smClient.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
		SecretId: aws.String(ref),
	})
	if err != nil {
		return Credentials{}, fmt.Errorf("fetch secret %s: %w", ref, err)
	}

	var c Credentials
	if err := json.Unmarshal([]byte(*out.SecretString), &c); err != nil {
		// Deliberately without the content in the message: an error
		// message with the secret in it ends up in the log.
		return Credentials{}, fmt.Errorf("secret %s is not in the expected shape", ref)
	}
	return c, nil
}

Two details in there matter more than the rest. The unmarshal error does not name the content; otherwise the password lands in the log the first time the format is off. And the call has a deadline: a service waiting forever on a secret store at start looks like a hung process to the orchestrator and gets restarted, over and over.

For access rights the same principle applies as everywhere: the service may read exactly that one secret, not all of them. That is one line in the policy and the difference between a compromised service and a compromised environment.

What goes wrong in the same place in an old PHP system is covered in Ten holes that sit in every old PHP system.

Rotation without a restart

If secrets get fetched at start, the question is what happens on rotation. The simplest answer is a restart, and for many services it is the right one: a rolling restart today is a matter of minutes with no interruption.

Where that is not enough, one pattern helps that needs no background process: the secret is not held as a value but behind a function that refreshes it on demand.

type Secret struct {
	mu     sync.RWMutex
	value  Credentials
	loaded time.Time
	fetch  func(context.Context) (Credentials, error)
}

// Get the value; if it is older than the interval, it gets refreshed.
// No background process, no timer: the refresh happens in the call
// that needs it.
func (s *Secret) Value(ctx context.Context) (Credentials, error) {
	s.mu.RLock()
	if time.Since(s.loaded) < 10*time.Minute {
		defer s.mu.RUnlock()
		return s.value, nil
	}
	s.mu.RUnlock()

	s.mu.Lock()
	defer s.mu.Unlock()
	// Second check: while waiting for the lock, another call may
	// already have refreshed.
	if time.Since(s.loaded) < 10*time.Minute {
		return s.value, nil
	}

	fresh, err := s.fetch(ctx)
	if err != nil {
		// The old value stays valid. An outage of the secret store must
		// not stop the service.
		return s.value, nil
	}
	s.value, s.loaded = fresh, time.Now()
	return fresh, nil
}

The last branch is the most important: when fetching fails, the service carries on with the old value. A secret store is a third-party system, and a service that stops when it fails has gained a dependency it does not need.

For database connections there is one speciality: a password change does not affect existing connections, only new ones. So it is enough to route connection setup through the function above; open connections age out, and that is exactly the behaviour you want.

Access to a secret is itself a permission: Cleaning up IAM in a grown AWS account.

What the PHP side notices

Nothing, and that is the point. The change concerns the new service, not the monolith.

That matters more than it sounds, because this is where projects regularly expand: if the Go service manages secrets properly, the idea of doing the same in the monolith is close at hand. That is right and it is a project of its own with a date of its own.

What is worth doing is one small piece of preparation: both sides use the same secret, not two copies. If the monolith reads its credentials from a .env and the service from the secret store, the same password sits in two places. At the next rotation one of them gets forgotten.

The smallest route there is a deployment step that generates the monolith's .env from the secret store. That is not a pretty solution, it is one line in the deployment script, and it removes the second source.

Local development

The model above has one weakness, and it shows on day one: nobody wants to talk to a secret store for a local run.

The solution is a second implementation of the same interface, selected through an environment variable.

type SecretSource interface {
	Fetch(ctx context.Context, ref string) (Credentials, error)
}

func chooseSource() SecretSource {
	if os.Getenv("ENVIRONMENT") == "local" {
		// Reads from a file that is not in the repository. Same
		// interface, so the path through the code stays the same.
		return &FileSource{Path: ".secrets.json"}
	}
	return &SecretsManagerSource{}
}

Two rules keep that clean. The file is in .gitignore, and there is an example file with the same keys and obviously wrong values that does live in the repository. And the local source is only chosen on an explicitly set environment variable, never as a fallback: a service quietly falling back to a file in production because the secret store is unreachable is precisely the failure this whole model exists to prevent.

How a Go service gets placed next to a PHP system technically is covered in its own article: Golang in a PHP world. How I set such a project up is on its own page.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.