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
SecretKeysigns 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.DB | the database every resource reads and writes through |
SecretKey | []byte | signs 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 48Appearance 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 |
Brand | Steward | the 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 |
GridActions | GridActionsButtons | how every grid presents a row’s actions — side by side, or GridActionsMenu behind one trigger. A single grid differs with Grid.ActionStyle |
DisableNotifications | false | hides the bell in the header and unmounts its endpoints. The table is still created, so turning it back on needs no migration |
FilterLayout | FiltersAbove | where 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 | ./uploads | LocalStorage’s root when no Storage is given |
Storage | LocalStorage at UploadDir | where 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 |
DefaultDisk | local | which disk an upload goes to when its field names none. With Disks set it must name one of them |
PublicUploads | false | makes the default disk public. Prefer a named disk with Public: true, which lets one panel keep both kinds |
SignedURLTTL | 15 minutes | how long a link to a private file stays good |
Data
| Default | ||
|---|---|---|
TablePrefix | admin_ | names the framework’s own tables. Process-global: two panels with different prefixes in one process are not supported |
BackgroundExportRows | 10000 | the match size past which a CSV export becomes a job rather than a download. Negative always streams, whatever the size |
DisableExportWorker | false | stops the panel building queued exports. Something else then has to call RunPendingExports — a worker — or they stay pending |
DisableQueryProbe | false | stops Verify running the statements a panel’s declarations build. They are bounded with LIMIT 0 and cost one round trip each |
DisableAutoMigrate | false | skips the framework’s embedded migrations at Build. Recommended in production — run them explicitly with the app’s migrate up instead |
Authentication
| Default | ||
|---|---|---|
Require2FA | false | makes TOTP enrolment mandatory; an account that has not enrolled can reach nothing but its profile. Bearer-token clients are exempt |
LoginCheck | — | runs 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 |
AuthExcept | — | path 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 | ||
|---|---|---|
EnableTokenAuth | false | accepts Authorization: Bearer <token> and mounts the token endpoints. Off by default because it exposes a credential-issuing endpoint |
TokenTTL | 30 days | how long an issued token stays valid; negative means never expires |
TokenRateLimit | 5 | attempts per window on the token endpoint, per username. Client IPs are capped at six times this. Zero means the default, negative disables limiting |
TokenRateWindow | 1 minute | the 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 | ||
|---|---|---|
Cache | in-process MemoryCache | see 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 |
Logger | slog.Default() | where the panel writes its logs |
Development
| Default | ||
|---|---|---|
Dev | false | re-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.