Skip to content
Configuration

Configuration

Everything a panel is given goes through one struct:

app, err := steward.New(steward.Config{
    DB:        db,
    SecretKey: key,
})

Only DB and SecretKey are required. Every other field has a default that makes a panel work, so this page is a reference rather than a checklist.

Where the values come from

Nothing here is read from the environment by Steward. Config is an ordinary struct built by your code, which is what lets the values come from anywhere — environment variables, a .env file, a secrets manager, a config file, or a mixture. The framework never reaches for os.Getenv on your behalf.

A generated project starts with environment variables, because they need no dependency:

func Build() (*steward.Panel, error) {
    db, err := gorm.Open(mysql.Open(env("DB_DSN", "")), &gorm.Config{})
    if err != nil {
        return nil, fmt.Errorf("opening database: %w", err)
    }
    return steward.New(steward.Config{
        DB:        db,
        SecretKey: []byte(os.Getenv("STEWARD_SECRET")),
        Dev:       os.Getenv("STEWARD_DEV") != "",
    })
}

A .env file

Steward bundles no loader, so pick one — godotenv is the usual choice — and load before building:

_ = godotenv.Load()            // no file in production is not an error
app, err := Build()

Loading a file only sets environment variables, so the code above does not change. Keep .env out of version control and out of the image.

A secrets manager

Build returns an error, so it is a fine place to fetch. Whatever the store — AWS Secrets Manager, Vault, Google Secret Manager — the shape is the same:

func Build(ctx context.Context) (*steward.Panel, error) {
    dsn, err := secrets.Get(ctx, "prod/panel/db")
    if err != nil {
        return nil, fmt.Errorf("reading the database secret: %w", err)
    }
    key, err := secrets.Get(ctx, "prod/panel/session-key")
    if err != nil {
        return nil, fmt.Errorf("reading the session key: %w", err)
    }

    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        return nil, err
    }
    return steward.New(steward.Config{DB: db, SecretKey: []byte(key)})
}

Two things to know before wiring one up:

  • Secrets are read once, at boot. Rotating one in the store does not reach a running process; restart it. That is usually what you want for a panel — the alternative is a credential that changes underneath an open session.
  • Rotating SecretKey signs everyone out. It is what sessions, CSRF tokens and remember cookies are signed with, so treat it as a planned action rather than routine hygiene.

Required

DB*gorm.DBthe database every resource reads and writes through
SecretKey[]bytesigns and encrypts sessions, CSRF and remember tokens. Minimum 16 bytes. Changing it signs everyone out

Dev permits a fixed development key; production must supply a real one. Generate it once and keep it out of the source:

openssl rand -base64 48

Appearance and mounting

Default
Prefixthe path the panel mounts under. Empty means the root, which is the default: a standalone panel needs no prefix. /app puts everything under /app
BrandStewardthe name in the sidebar and page titles
BrandIcona Lucide name for the panel’s own mark, beside the brand and alone when the sidebar is collapsed to its rail. Unset, the brand’s first letter is used
ThemeCSSCSS inlined in every page’s head, after the stylesheet, for redefining the design tokens
CurrencySymbol$prefixes every Currency field; one field overrides it with Symbol
GridActionsGridActionsButtonshow every grid presents a row’s actions — side by side, or GridActionsMenu behind one trigger. A single grid differs with Grid.ActionStyle
DisableNotificationsfalsehides the bell in the header and unmounts its endpoints. The table is still created, so turning it back on needs no migration
FilterLayoutFiltersAbovewhere every grid’s filter panel lives — between the toolbar and the rows, or FiltersDrawer over the page from the right. A single grid differs with Grid.FilterLayout

Mounting at the root

With no Prefix, the panel is the site: the dashboard is /, a grid is /posts, and anything unmatched is the panel’s own 404 page. That is the default because a panel is usually its own binary, where a prefix buys nothing.

Give it one when the panel shares an origin with something else:

steward.Config{Prefix: "/app"}   // the dashboard moves to /app/

Either way, mount it as a handler and let it own its paths:

mux := http.NewServeMux()
mux.Handle("/api/", myAPI)   // more specific patterns still win
mux.Handle("/", app)

Build links with c.URL("posts") rather than writing the path out, and the prefix stops being something you have to remember.

Storage

Default
UploadDir./uploadsLocalStorage’s root when no Storage is given
StorageLocalStorage at UploadDirwhere files go when no disk names another
Disksnamed places files can be stored, each public or private
ExportDiskwhere finished background exports are written. Empty means DefaultDisk
DefaultDisklocalwhich disk an upload goes to when its field names none. With Disks set it must name one of them
PublicUploadsfalsemakes the default disk public. Prefer a named disk with Public: true, which lets one panel keep both kinds
SignedURLTTL15 minuteshow long a link to a private file stays good

Data

Default
TablePrefixadmin_names the framework’s own tables. Process-global: two panels with different prefixes in one process are not supported
BackgroundExportRows10000the match size past which a CSV export becomes a job rather than a download. Negative always streams, whatever the size
DisableExportWorkerfalsestops the panel building queued exports. Something else then has to call RunPendingExports — a worker — or they stay pending
DisableQueryProbefalsestops Verify running the statements a panel’s declarations build. They are bounded with LIMIT 0 and cost one round trip each
DisableAutoMigratefalseskips the framework’s embedded migrations at Build. Recommended in production — run them explicitly with the app’s migrate up instead

Authentication

Default
Require2FAfalsemakes TOTP enrolment mandatory; an account that has not enrolled can reach nothing but its profile. Bearer-token clients are exempt
LoginCheckruns after the password and second factor are accepted, before the session is issued. Returning an error refuses the login and shows its message. It can only withhold a login, never grant one
AuthExceptpath patterns (relative to Prefix, * globs) that skip authentication and permission checks
// "suspended", "not yet activated", "outside permitted hours"
LoginCheck: func(ctx context.Context, u *steward.AdminUser) error {
    if suspended(u.ID) {
        return errors.New("This account is suspended.")
    }
    return nil
},

Warning

AuthExcept opens a path to anyone who can reach the panel. It exists for health checks and public endpoints, not for convenience during development.

API tokens

Default
EnableTokenAuthfalseaccepts Authorization: Bearer <token> and mounts the token endpoints. Off by default because it exposes a credential-issuing endpoint
TokenTTL30 dayshow long an issued token stays valid; negative means never expires
TokenRateLimit5attempts per window on the token endpoint, per username. Client IPs are capped at six times this. Zero means the default, negative disables limiting
TokenRateWindow1 minutethe window those attempts are counted in

Tokens inherit their user’s roles, permissions and policies. Scope an API client by giving it its own account with a restricted role — not the administrator’s.

Integrations

Default
Cachein-process MemoryCachesee caching
Searcherbacks quick search and the palette for resources that declared Searchable. Without one they fall back to SQL LIKE
Mailersetting one enables the password-reset flow
Loggerslog.Default()where the panel writes its logs

Development

Default
Devfalsere-parses templates on every request and serves assets uncached. Also permits a fixed development secret
TemplatesFSoverlays the embedded templates; files here win
AssetsFSoverlays the embedded assets, e.g. an extra icon at icons/name.svg

Settings: values that change at runtime

Config is fixed when the process starts. For values someone should be able to change without a deploy — a notice on the login page, a contact address, whether registrations are open — there is a slug→value store with a page of its own under Admin → Settings.

notice, err := app.Setting(ctx, "login-notice")
if err != nil {
    return err
}

if err := app.SetSetting(ctx, "login-notice", "Maintenance on Sunday."); err != nil {
    return err
}

Reads are cached for ten minutes and the form invalidates that entry on save, so a change made in the panel takes effect at once. An absent slug reads as "" rather than an error, which keeps a caller from having to distinguish “not set” from “set to nothing” — but also means a typo in a slug is silent, and so is a setting nobody reads. The page will happily store contct-email; only your code decides that it matters.

Important

The default cache is per process, so with several replicas a write invalidates only the one that served it and the others serve the old value until the TTL lapses. Configure a shared cache for any deployment running more than one instance.

Checking it at boot

Verify reports what New and Build could not: a field path that does not resolve, an icon that is not in the sprite, a badge colour outside the set, a disk no Disks entry names, a validation rule that does not exist.

if err := app.Build(); err != nil {
    return err
}
if err := app.Verify(); err != nil {
    return err   // in development; log it in production if you prefer
}

It is worth calling in a test, so a panel that cannot be configured correctly fails the build rather than the first page that renders it.