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
Prefix—the 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
BrandIcon—a 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
ThemeCSS—CSS 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
Disks—named places files can be stored, each public or private
ExportDisk—where 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
TablePrefixsteward_names the framework’s own tables. Installs created before v0.6.0 used admin_ and must set it explicitly (see the CHANGELOG’s upgrading notes). 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
Tenancynilturns on multi-tenancy: tenants, memberships, per-tenant roles, a workspace switcher, and automatic scoping of every model embedding steward.TenantScoped. Nil keeps the panel single-tenant

Authentication

Default
Require2FAfalsemakes TOTP enrolment mandatory; an account that has not enrolled can reach nothing but its profile. Bearer-token clients are exempt
LoginCheck—runs once the account is identified (password or SSO), before the session is issued. With two-factor it runs before the challenge and again when it is passed. Returning an error refuses the login and shows its message. It can only withhold a login, never grant one
AuthExcept—path patterns (relative to Prefix, * globs) that skip authentication and permission checks
AuthProviders—external sign-in providers shown on the login page. Empty means none: no SSO routes and no buttons
AuthProviderSource—providers looked up per request, such as a tenant’s SAML connection. Adds the organization field to the login page
ResolveExternalUser—maps an external identity that is not linked yet to an account. Use it for just-in-time provisioning. Unset, only linked identities (and verified email matches, with LinkByVerifiedEmail) sign in
LinkByVerifiedEmailfalselinks an external identity to the account with the same email when the provider says the email is verified
DisablePasswordLoginfalsehides the username/password form and refuses its submissions
PublicURL—scheme and host browsers reach the panel on, for SSO callback URLs. Empty derives it from the request
TrustProxyfalsetrusts X-Forwarded-Proto and X-Forwarded-Host for secure cookies and callback URLs. Enable only behind a proxy that sets them
// "suspended", "not yet activated", "outside permitted hours"
LoginCheck: func(ctx context.Context, u *steward.User) 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
Searcher—backs quick search and the palette for resources that declared Searchable. Without one they fall back to SQL LIKE
Mailer—setting 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
TemplatesFS—overlays the embedded templates; files here win
AssetsFS—overlays 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.