Chapter 11

Configuration Management

iris.Options as one struct, built in code, loaded from YAML or read from SERVER_CONFIG. Merge semantics, defaults and multi-environment setups.

Iris keeps its configuration in one plain struct: iris.Options. You build the value in code, load it from YAML, or read it from an environment variable, then hand it to iris.New or iris.NewBuilder. Every field is optional because the framework merges what you provide with its defaults. This chapter walks through the struct, the merge rules that make partial configuration safe, the loading helpers, the router and context option tours, and the server options passed to app.Run.

Background

Configuration is every value your program needs that is not part of its logic: which port to listen on, how verbose the logs are, which database to talk to, which password unlocks the admin account. The same compiled binary typically runs in several environments (your laptop, a staging server, the production cluster) and each needs different values. If those values live in the source code, every difference forces a rebuild, and every credential becomes visible to anyone who can read the code. The twelve-factor methodology, a widely cited set of principles for deployable applications, distills this into one rule: keep configuration in the environment, so the code stays identical across deployments and only the surrounding values change.

Two carriers for configuration appear throughout this chapter. A YAML file is a plain-text format for structured data: key: value pairs, nesting expressed by indentation, lists introduced with -. People can read and edit it easily, which makes it the usual choice for settings that live next to the code, such as development defaults. An environment variable is a named string the operating system hands a process when it starts. It is set outside the program (by the shell, a container runtime, a systemd unit or an orchestration tool) and read at run time with a call like os.Getenv. Because every deployment platform knows how to inject environment variables, they are the natural channel for anything that changes per deployment.

A secret is a configuration value that grants access: a password, an API key, a signing key, a database connection string. Secrets never belong in a repository, even a private one. Version control remembers everything, so a key committed once stays in the history long after the file is “fixed”; clones, forks, backups and CI machines all receive a copy; and revoking a leaked key is far more painful than never leaking it. The safe home for secrets is the environment, filled either by hand or by a secret manager (a vault service that stores secrets encrypted and injects them at deploy time).

Iris makes this discipline cheap to follow. One struct, iris.Options, holds all framework settings; it merges with sensible defaults, loads from YAML or a single environment variable, and embeds into your own config struct. What disappears from your code is the configuration plumbing itself: no flag-parsing forest, no per-environment build tags, no hand-rolled merge of defaults against overrides.

The Options Struct

Go
package main

import "github.com/kataras/iris/v14"

func main() {
    app := iris.New(iris.Options{
        Name:     "bookstore",
        LogLevel: "debug",
        Host:     "example.com",
    })

    app.Get("/", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"status": "ok"})
    })

    app.Listen(":8080")
}

iris.Options has five fields, all optional:

FieldTypePurpose
NamestringApplication name, used as the log prefix of the application’s logger.
LogLevelstring"disable", "fatal", "error", "warn", "info" (default) or "debug".
HoststringThe trusted domain the server runs on, e.g. "example.com". Also copied to Router.Host, which subdomain routing relies on.
Routeriris.RouterOptionsRouting behavior for all routes.
Contextiris.ContextOptionsRequest and response handling for all routes.

Options implements the iris.Configurator interface, and iris.New accepts any number of configurators. For imperative setup steps you can also pass an iris.ConfiguratorFunc(func(app *iris.Application) { ... }), which runs against the application instance at construction time.

Merge Semantics

An Options literal passed to iris.New merges with iris.DefaultOptions: every field left at its zero value is filled from the defaults. A partial literal therefore configures only what it sets. The example above changes the name and the log level; the charset stays "utf-8", the multipart form limit stays 32 MB, and so on.

Two refinements to that rule matter in practice.

A non-nil empty slice or map is an explicit “none”. nil means “unset, give me the default”, while an allocated empty value means “I want nothing here”. The field where the distinction is visible out of the box is RemoteAddrPrivateSubnets, whose default is a list of common private IP ranges:

Go
import (
    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/core/pkg/netutil"
)

app := iris.New(iris.Options{
    Context: iris.ContextOptions{
        // nil would inherit the default private ranges;
        // an empty non-nil slice clears them.
        RemoteAddrPrivateSubnets: []netutil.IPRange{},
    },
})

A meaningful zero cannot be expressed at construction. For scalar fields the merge cannot tell “unset” apart from “deliberately zero”. Setting URLParamSeparator: "" in the literal does nothing, because the empty string counts as unset and is replaced by the default ",". The escape hatch is per-request mutation: ctx.Options() returns a request-scoped copy of the context options that a middleware may edit freely, and the change dies with the request:

Go
app.Use(func(ctx iris.Context) {
    // Treat "a,b" as one value instead of splitting on commas,
    // for this request only.
    ctx.Options().URLParamSeparator = ""
    ctx.Next()
})

The mechanism is not limited to zeros. Any handler can adjust the context options for the request it serves. A route group that accepts large uploads, for example, can raise BodyMaxMemory in its middleware while the rest of the application keeps the strict global cap.

Defaults and EditOptions

iris.DefaultOptions is the exported default configuration. When you want the defaults plus a few edits, iris.Defaults reads better than a literal because you modify the defaults in place:

Go
app := iris.New(iris.Defaults(func(opts *iris.Options) {
    opts.LogLevel = "debug"
    opts.Context.RemoteAddrHeaders = []string{"X-Forwarded-For"}
}))

After construction, app.EditOptions applies further changes:

Go
app := iris.New()

app.EditOptions(func(opts *iris.Options) {
    opts.LogLevel = "warn"
    opts.Context.BodyMaxMemory = 8 * iris.MB
})

EditOptions must run before the application is built, that is before Build, Listen or Run. Once built it only logs a warning and the change may not be applied, so never call it from inside a request handler. For per-request changes use ctx.Options() as shown above.

YAML Files and the SERVER_CONFIG Variable

Two helpers load Options from the outside world.

iris.YAML reads a YAML file, applies it over the defaults and panics when the file is missing or malformed. It fits programs where the file is required:

Go
app := iris.New(iris.YAML("config.yml"))

The YAML keys follow the struct’s YAML tags, which are CamelCase and nest the router and context sections:

YAML
# config.yml
Name: bookstore
LogLevel: info
Host: example.com
Router:
    ForceLowercaseRouting: true
    FireMethodNotAllowed: true
Context:
    BodyMaxMemory: 4194304 # 4 MB
    RemoteAddrHeaders:
        - X-Forwarded-For

(*Options).Bind is environment-first: it reads the SERVER_CONFIG environment variable as a full YAML document and only falls back to the given file when the variable is empty. It returns an error instead of panicking:

Go
package main

import (
    "log"

    "github.com/kataras/iris/v14"
)

func main() {
    var opts iris.Options
    if err := opts.Bind("config.yml"); err != nil {
        log.Fatal(err)
    }

    app := iris.New(opts)
    app.Listen(":8080")
}

One container variable can carry the entire configuration, so a deployment does not need to mount files. Fields the document leaves out still inherit the defaults when the value reaches iris.New.

All four inputs meet at the same merge, and the two rules that catch people out are consequences of it: an empty-but-non-nil collection survives, and a meaningful zero does not.

How iris.Options reaches the application Whichever way you supply options, every field you left unset is filled from the defaults, Configure applies them, EditOptions may re-run the merge until Build freezes it, and ctx.Options() is the per-request escape hatch afterwards. INPUTS: WHICHEVER ONE YOU USE iris.Options{...} a literal, or iris.Defaults(edits...) iris.YAML(file) a required file: it panics when missing Options.Bind(file) SERVER_CONFIG first, the file as fallback applyDefaults(): every unset field is filled from iris.DefaultOptions A partial value configures only what it sets. It never zeroes the rest. a non-nil empty slice or map is kept it reads as an explicit "none": RemoteAddrPrivateSubnets: []netutil.IPRange{} clears the default private ranges a meaningful zero cannot be set here URLParamSeparator: "" reads as unset and is replaced by ",". Use ctx.Options() per request instead. Options.Configure(app) Host is copied to Router.Host, and the Context options are applied and copied to the root router app.EditOptions(fn) re-runs the same merge. Optional and repeatable, but only before Build, Listen or Run: after that it only logs a warning Build() the configuration is frozen ctx.Options() a request-scoped copy any handler may edit; the change dies with the request. This is the escape hatch for a meaningful zero, and for a per-group limit such as BodyMaxMemory.
Whichever way you supply options, every field you left unset is filled from the defaults, Configure applies them, EditOptions may re-run the merge until Build freezes it, and ctx.Options() is the per-request escape hatch afterwards.

Router Options

iris.RouterOptions controls matching behavior for all routes. Every boolean defaults to false.

FieldEffect
HostThe server’s host, including a non-standard port. Set automatically from Options.Host; subdomain routing depends on it.
DisablePathCorrectionBy default a request to /home/ is redirected (301) to a registered /home. Set to true to disable.
DisablePathCorrectionRedirectionWith path correction on, execute the corrected route’s handler directly instead of redirecting.
EnablePathIntelligenceRedirect not-found GET requests to the closest registered path, e.g. /cont to /contact.
EnablePathEscapeKeep encoded characters such as %2F inside path parameters.
ForceLowercaseRoutingRegister and match all paths lowercased.
AllowQuerySemicolonsConvert unescaped ; in the query to & (the pre-Go 1.17 behavior).
EnableDynamicHandlerAllow adding routes at serve time via RefreshRouter, at the cost of a routing mutex on every request.
FireMethodNotAllowedAnswer 405 instead of 404 when the path matches but the method does not. A builder application answers it with the rest/errors JSON envelope; for your own body, register HandleNoMethod from a Configurator passed to NewBuilder, because the builder fills that slot at Build (Chapter 3, Chapter 4).
Go
app := iris.New(iris.Options{
    Router: iris.RouterOptions{
        ForceLowercaseRouting: true,
        FireMethodNotAllowed:  true,
    },
})

Context Options

iris.ContextOptions configures request and response handling. The same struct is editable per request through ctx.Options().

Request fields:

FieldDefaultEffect
URLParamSeparator","Separator used to split multi-value entries in ctx.Query(), ctx.Form() and ctx.PostForm().
FireEmptyFormErrorfalseMake ReadForm, ReadQuery and ReadBody return iris.ErrEmptyForm on empty form data instead of succeeding silently.
TimeFormat"Mon, 02 Jan 2006 15:04:05 GMT"Datetime parsing format, used for example by ctx.CheckIfModifiedSince.
PostMaxMemory32 MBMemory cap for multipart form parsing.
BodyMaxMemory0 (no limit)Byte cap for Body, ReadJSON, ReadJSONStream, ReadMultipartRelated and the readers built on them, including ctx.BindJSON. Exceeding it fails with the context package’s ErrRequestEntityTooLarge, which rest/errors maps to 413.
RemoteAddrHeadersnoneHeader names trusted to reveal the real client IP in ctx.RemoteAddr(). Empty by default: no X- header is trusted unless you list it.
RemoteAddrHeadersForcefalseUse the first header entry as the client IP even when it falls inside a private subnet.
RemoteAddrPrivateSubnetscommon private rangesIP ranges treated as private when resolving the client IP.
SSLProxyHeadersnoneHeader name/value pairs that mark a request as HTTPS for ctx.IsSSL(), e.g. {"X-Forwarded-Proto": "https"}.
HostProxyHeadersnoneHeaders that may carry the proxied hostname for ctx.Host().
RequestValidatornilCalled after body decoding on ReadJSON, ReadForm, ReadQuery and friends.

Response fields:

FieldDefaultEffect
Charset"utf-8"Character set appended to rendered Content-Type headers.
ErrorHandlerframework defaultHandles errors produced while writing responses through JSON, XML, Render and the other writers.
DisableErrorHandlerfalseTurn the write-error handler off entirely.

In production, requests often reach your server through a reverse proxy: an intermediary such as a load balancer, nginx or a CDN that accepts the client’s connection and forwards it to your application. To your program every request then appears to come from the proxy’s own IP, often over plain HTTP, so proxies attach headers such as X-Forwarded-For (the original client IP) and X-Forwarded-Proto (the original scheme). These headers are ordinary text that any client could forge, which is why Iris trusts none of them until you explicitly list the ones your own proxy sets.

A typical proxy-aware production configuration:

Go
app := iris.New(iris.Options{
    Context: iris.ContextOptions{
        BodyMaxMemory:     8 * iris.MB,
        RemoteAddrHeaders: []string{"X-Forwarded-For", "X-Real-Ip"},
        SSLProxyHeaders: map[string]string{
            "X-Forwarded-Proto": "https",
        },
    },
})

BodyMaxMemory is the application-wide cap; for per-route limits use the bodylimit middleware instead (Chapter 12).

RequestValidator accepts any value with a Struct(any) error method. The third-party github.com/go-playground/validator/v10 package satisfies it directly:

Go
import "github.com/go-playground/validator/v10"

app.EditOptions(func(opts *iris.Options) {
    opts.Context.RequestValidator = validator.New()
})

See Chapter 5 for the body readers themselves.

Embedding Options in Your Own Config

Real applications have settings of their own next to the framework’s. Embedding iris.Options inline in the application’s config struct makes one YAML document configure both, and the loader below is that shape written out in full, because it is worth seeing once. It is not what the tutorial or the rest of this book does any more: they embed iris.BaseConfiguration, which carries Host, Port, AllowOrigin, EnableCompression, RequestLog and the framework options under an Iris key, and they load it with the function the framework ships. The next section is the version to copy into a new service.

Go
package main

import (
    "fmt"
    "log"
    "os"

    "gopkg.in/yaml.v3"

    "github.com/kataras/iris/v14"
)

// Config holds the whole application configuration.
// AdminPassword is yaml:"-" on purpose: secrets come from the
// environment, never from a committed file.
type Config struct {
    iris.Options `yaml:",inline"`

    Addr          string `yaml:"Addr"`
    Env           string `yaml:"Env"`
    AdminUser     string `yaml:"AdminUser"`
    AdminPassword string `yaml:"-"`
}

// LoadConfig starts from working defaults, overlays YAML from the
// APP_CONFIG environment variable or the config.yml file (both
// optional), then reads the secrets from the environment.
func LoadConfig() (Config, error) {
    cfg := Config{
        Options:   iris.DefaultOptions,
        Addr:      ":8080",
        Env:       "development",
        AdminUser: "admin",
    }

    if raw := os.Getenv("APP_CONFIG"); raw != "" {
        if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil {
            return cfg, fmt.Errorf("config from env: %w", err)
        }
    } else if data, err := os.ReadFile("config.yml"); err == nil {
        if err = yaml.Unmarshal(data, &cfg); err != nil {
            return cfg, fmt.Errorf("config.yml: %w", err)
        }
    }

    cfg.AdminPassword = os.Getenv("ADMIN_PASSWORD")
    if cfg.AdminPassword == "" {
        return cfg, fmt.Errorf("ADMIN_PASSWORD is required")
    }
    return cfg, nil
}

func main() {
    cfg, err := LoadConfig()
    if err != nil {
        log.Fatal(err)
    }

    app := iris.New(cfg.Options)
    // With the builder instead: iris.NewBuilder(cfg.Options)...
    // (see Chapter 3).

    app.Listen(cfg.Addr)
}

The committed config.yml contains settings only, no secrets:

YAML
# Committed settings only. The admin password comes from
# the ADMIN_PASSWORD environment variable.
Name: bookstore
LogLevel: info
Addr: ":8080"
Env: development
AdminUser: admin

The yaml:",inline" tag makes the framework keys (Name, LogLevel, Host, Router, Context) appear at the top level of the same document as your own keys. The loading order mirrors Options.Bind: environment variable first, file as the development fallback. Never commit credentials, key material or connection strings, even in private repositories; the environment, or a secret manager feeding it, is their only home.

The loader the framework ships

That LoadConfig function is the same forty-five lines in every service that has one, so the framework has its own:

Go
type Configuration struct {
    iris.BaseConfiguration `yaml:",inline"`

    DatabaseURL string `yaml:"DatabaseURL"`
}

func main() {
    c, err := iris.LoadConfiguration[Configuration](
        "MY_SERVICE_CONFIG", "configuration.yml")
    if err != nil {
        log.Fatal(err)
    }

    app := iris.New(c.Iris)
    // ...routes...
    app.Listen(c.Addr())
}

iris.LoadConfiguration[T] reads a YAML document into T from the named environment variable when it is set, otherwise from the file. The variable may hold either the document or a path to it: a value naming an existing file is read as a file and anything else is decoded as YAML, so the same binary works whether the deployment mounts the configuration or passes it inline. It is not a generic Options.Bind; that one is hardcoded to SERVER_CONFIG and always reads its value as a document.

If T (or *T) has a BindEnv() method it runs after decoding, which is what makes the environment win over the document. Embedding iris.BaseConfiguration gets that for the fields every service shares:

FieldYAML keyMeaning
HostHostThe interface to listen on; empty means every interface. Not Iris.Host, which is the trusted domain the router matches subdomains against
PortPortThe TCP port; zero means 8080
AllowOriginAllowOriginThe origin line, as Builder.AllowOrigin accepts it
EnableCompressionEnableCompressionAs Builder.Compression accepts it
RequestLogRequestLogAs Builder.LogRequests accepts it
IrisIrisThe framework Options, as iris.New accepts them

Its BindEnv reads HOST, PORT, HTTP_PLATFORM_PORT and ALLOW_ORIGIN. HTTP_PLATFORM_PORT wins over PORT, because a hosting platform that picks the port passes it there while PORT may still carry the value baked into the image. A non-numeric port is ignored rather than turned into zero, so a typo cannot silently move the server to 8080. Addr() returns the host:port string for app.Listen, joined so that an IPv6 host is bracketed correctly.

There is deliberately no app.Serve(host, port). Listen and Run stay the only two entry points, and Addr() is the part that was missing.

Multi-Environment Setups

Because the whole configuration travels as one YAML document, environments do not need file hierarchies or override matrices. Keep the development file in the repository, and let each deployment carry its own document in the environment variable:

YAML
# deploy/production.yml, injected as APP_CONFIG
Name: bookstore
LogLevel: warn
Host: api.example.com
Addr: ":8080"
Env: production
Context:
    RemoteAddrHeaders:
        - X-Forwarded-For
    SSLProxyHeaders:
        X-Forwarded-Proto: https
Shell
# The deployment pipeline sets the variable; locally you can do:
APP_CONFIG="$(cat deploy/production.yml)" ./server

A Kubernetes ConfigMap, a systemd unit or a compose file sets the same variable the same way. Unset fields still merge with the defaults, so each document stays as small as the differences between environments.

Server Options

Serving is configured separately from the application, at the call to app.Run. app.Listen(addr) is the shorthand; the two are equivalent:

Go
app.Listen(":8080")

// is the same as:
app.Run(iris.ServerOptions{
    Addr:           ":8080",
    Network:        "tcp",
    KeepAlive:      3 * time.Minute,
    SocketSharding: true,
})

The fields of iris.ServerOptions:

FieldPurpose
AddrListen address in "host:port" form. Empty means ":http", or ":https" when TLSConfig is set.
Network"tcp" (default), "tcp4", "tcp6", "unix" or "unixpacket".
KeepAliveKeep-alive period for accepted connections; negative disables keep-alives.
SocketShardingSO_REUSEPORT (SO_REUSEADDR on Windows) for multi-CPU scaling.
HostsHostnames the TLS certificate is valid for; falls back to the application’s Host, then to Addr.
TLSConfigCustom *tls.Config.
CertFile, KeyFilePaths to PEM-encoded certificate and key files.
LetsencryptAutomatic HTTPS via Let’s Encrypt.
Ngrok, SSHTunnelExpose the local server through an ngrok or SSH reverse tunnel (Chapter 19). Ngrok needs the blank import _ "github.com/kataras/iris/v14/core/server/ngrok"; without it the server refuses to start.
Timeout*iris.TimeoutOptions, see below.
LogWriterDestination for errors surfaced by the underlying http.Server.
DisableStartupLogSuppress the startup message.
DisableInterruptHandlerSkip the built-in interrupt handler that shuts the server down gracefully on CTRL+C.

TLS (Transport Layer Security) is the encryption layer that turns HTTP into HTTPS. A server proves its identity with a certificate (a file, signed by a trusted certificate authority, that binds a hostname to a public key) and keeps the matching private key to itself. Let’s Encrypt is a certificate authority that issues certificates free of charge through an automated protocol; the Letsencrypt option drives that protocol for you, so the server obtains and renews its own certificates instead of you installing files.

Use one TLS mechanism at a time: TLSConfig, the CertFile/KeyFile pair, Letsencrypt, Ngrok or SSHTunnel. The one sanctioned combination is Letsencrypt with CertFile/KeyFile, where the files act as a local fallback when the Let’s Encrypt challenge fails.

Serving TLS from certificate files:

Go
app.Run(iris.ServerOptions{
    Addr:     ":443",
    CertFile: "server.crt",
    KeyFile:  "server.key",
})

Automatic HTTPS:

Go
app.Run(iris.ServerOptions{
    Addr:        ":443",
    Hosts:       []string{"api.example.com"},
    Letsencrypt: true,
})

Timeouts

A timeout is the maximum time you allow a network operation before giving up on it. Servers need timeouts because clients can be slow or hostile on purpose: a connection trickling one byte per second would otherwise hold memory and a file descriptor forever. Keep-alive, mentioned below, is the practice of reusing one TCP connection for several requests instead of opening a fresh connection each time; the idle timeout bounds how long such a reused connection may sit waiting for its next request.

iris.TimeoutOptions holds the standard net/http server timeouts plus an optional handler timeout:

FieldEffect
ShutdownOnInterruptTimeoutHow long graceful shutdown may take on interrupt. Defaults to 15 seconds.
ServerTimeoutOne knob from which the read-header, write and idle timeouts are derived.
ReadTimeoutMaximum duration to read the whole request, body included.
ReadHeaderTimeoutTime allowed to read request headers; handlers keep control over body deadlines.
WriteTimeoutMaximum duration for writing the response.
IdleTimeoutKeep-alive wait between requests.
HandlerTimeoutWraps the router in http.TimeoutHandler when greater than zero.
HandlerTimeoutMessageHTML body sent when the handler timeout fires.

Start from the iris.Timeout() builder so the shutdown timeout and the default timeout message stay in place, then set what you need:

Go
package main

import (
    "time"

    "github.com/kataras/iris/v14"
)

func main() {
    app := iris.New()

    app.Get("/", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"status": "ok"})
    })

    app.Run(iris.ServerOptions{
        Addr: ":8080",
        Timeout: iris.Timeout().
            WithServerTimeout(30 * time.Second).
            WithHandlerTimeout(15 * time.Second),
    })
}

Unless DisableInterruptHandler is set, the server catches the interrupt signal, stops accepting connections and waits up to ShutdownOnInterruptTimeout for in-flight requests to finish.

Summary

  • iris.Options (Name, LogLevel, Host, Router, Context) is the whole application configuration; every field is optional.
  • Options merge with iris.DefaultOptions: partial literals are safe, a non-nil empty slice or map means “explicitly none”, and meaningful zeros are set per request through ctx.Options() in a middleware.
  • iris.Defaults(edits...) tweaks the defaults in place; app.EditOptions applies changes before the application is built, never from a handler.
  • iris.YAML("config.yml") loads a required file; (*Options).Bind reads the SERVER_CONFIG environment variable first and falls back to a file.
  • Embed iris.Options into your own config struct with yaml:",inline" for one document that configures both the framework and your application; keep secrets in the environment only.
  • Serving is configured at app.Run(iris.ServerOptions{...}): address, TLS (files, custom config or Let’s Encrypt), keep-alive, socket sharding and timeouts via iris.Timeout().

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.