Configuration
On this page 20
Stacks provides a powerful, type-safe configuration system that centralizes all your application settings. Configure everything from database connections to cloud deployments in one place.
Overview
Configuration in Stacks follows these principles:
- Type-safe - Full TypeScript support with autocompletion
- Environment-aware - Different configs for dev, staging, production
- Centralized - All settings in one organized location
- Validated - Configuration errors caught at startup
Configuration Files
All configuration lives in the config/ directory:
config/
├── app.ts # Application settings
├── database.ts # Database connections
├── cache.ts # Cache configuration
├── queue.ts # Queue settings
├── mail.ts # Email configuration
├── storage.ts # File storage
├── auth.ts # Authentication
├── cloud.ts # Cloud deployment
├── hashing.ts # Password hashing
├── logging.ts # Log configuration
├── services.ts # Third-party services
├── server.ts # Views server / API proxy
└── index.ts # Config export
Application Config
// config/app.ts
import { defineApp } from '@stacksjs/config'
export default defineApp({
name: 'My App',
env: process.env.APP_ENV || 'development',
debug: process.env.APP_DEBUG === 'true',
url: process.env.APP_URL || 'http://localhost:3000',
timezone: 'UTC',
locale: 'en',
key: process.env.APP_KEY,
providers: [
// Service providers to load
],
})
Database Config
// config/database.ts
import { defineDatabase } from '@stacksjs/config'
export default defineDatabase({
default: process.env.DB_CONNECTION || 'sqlite',
connections: {
sqlite: {
driver: 'sqlite',
database: process.env.DB_DATABASE || 'database/database.sqlite',
},
mysql: {
driver: 'mysql',
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 3306,
database: process.env.DB_DATABASE || 'stacks',
username: process.env.DB_USERNAME || 'root',
password: process.env.DB_PASSWORD || '',
charset: 'utf8mb4',
collation: 'utf8mb4_unicode_ci',
},
postgres: {
driver: 'postgres',
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 5432,
database: process.env.DB_DATABASE || 'stacks',
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || '',
schema: 'public',
},
},
migrations: {
table: 'migrations',
path: 'database/migrations',
},
seeders: {
path: 'database/seeders',
},
})
Cache Config
// config/cache.ts
import { defineCache } from '@stacksjs/config'
export default defineCache({
default: process.env.CACHE_DRIVER || 'file',
stores: {
file: {
driver: 'file',
path: 'storage/cache',
},
redis: {
driver: 'redis',
connection: 'cache',
prefix: 'cache:',
},
memory: {
driver: 'memory',
maxSize: 100 _ 1024 _ 1024, // 100MB
},
dynamodb: {
driver: 'dynamodb',
table: process.env.DYNAMODB_CACHE_TABLE || 'cache',
region: process.env.AWS_REGION || 'us-east-1',
},
},
prefix: process.env.CACHE_PREFIX || 'stacks_cache_',
ttl: 3600, // 1 hour default
})
Queue Config
// config/queue.ts
import { defineQueue } from '@stacksjs/config'
export default defineQueue({
default: process.env.QUEUE_CONNECTION || 'sync',
connections: {
sync: {
driver: 'sync',
},
database: {
driver: 'database',
table: 'jobs',
queue: 'default',
retryAfter: 90,
},
redis: {
driver: 'redis',
connection: 'default',
queue: 'default',
retryAfter: 90,
blockFor: 5,
},
sqs: {
driver: 'sqs',
key: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION || 'us-east-1',
prefix: process.env.SQS_PREFIX,
queue: process.env.SQS_QUEUE || 'default',
},
},
failed: {
driver: 'database',
table: 'failed_jobs',
},
})
Mail Config
// config/mail.ts
import { defineEmail } from '@stacksjs/config'
export default defineEmail({
default: process.env.MAIL_MAILER || 'smtp',
mailers: {
smtp: {
driver: 'smtp',
host: process.env.MAIL_HOST || 'localhost',
port: Number(process.env.MAIL_PORT) || 587,
encryption: process.env.MAIL_ENCRYPTION || 'tls',
username: process.env.MAIL_USERNAME,
password: process.env.MAIL_PASSWORD,
},
ses: {
driver: 'ses',
region: process.env.AWS_REGION || 'us-east-1',
},
mailgun: {
driver: 'mailgun',
domain: process.env.MAILGUN_DOMAIN,
secret: process.env.MAILGUN_SECRET,
endpoint: process.env.MAILGUN_ENDPOINT || 'api.mailgun.net',
},
log: {
driver: 'log',
channel: 'mail',
},
},
from: {
address: process.env.MAIL_FROM_ADDRESS || 'hello@example.com',
name: process.env.MAIL_FROM_NAME || 'Stacks App',
},
})
Storage Config
// config/storage.ts
import { defineStorage } from '@stacksjs/config'
export default defineStorage({
default: process.env.FILESYSTEM_DISK || 'local',
disks: {
local: {
driver: 'local',
root: 'storage/app',
visibility: 'private',
},
public: {
driver: 'local',
root: 'storage/app/public',
url: '/storage',
visibility: 'public',
},
s3: {
driver: 's3',
bucket: process.env.AWS_BUCKET,
region: process.env.AWS_REGION || 'us-east-1',
url: process.env.AWS_URL,
endpoint: process.env.AWS_ENDPOINT,
forcePathStyle: process.env.AWS_USE_PATH_STYLE === 'true',
},
},
links: {
'public/storage': 'storage/app/public',
},
})
Auth Config
// config/auth.ts
import { defineAuth } from '@stacksjs/config'
export default defineAuth({
defaults: {
guard: 'web',
provider: 'users',
},
guards: {
web: {
driver: 'session',
provider: 'users',
},
api: {
driver: 'token',
provider: 'users',
hash: false,
},
},
providers: {
users: {
driver: 'database',
model: 'User',
},
},
passwords: {
users: {
provider: 'users',
table: 'password_reset_tokens',
expire: 60,
throttle: 60,
},
},
session: {
lifetime: 120,
expireOnClose: false,
},
})
Server Config
Public component modules embedded on another domain need CORS headers on their
JavaScript files, including relative runtime imports. Configure only those
asset paths; both buddy dev and buddy serve apply these rules after their
usual security headers. Exact paths and trailing /* prefixes are supported.
export default {
headers: {
'/assets/elements/*': {
'Access-Control-Allow-Origin': '*',
'Cross-Origin-Resource-Policy': 'cross-origin',
},
},
} satisfies ServerConfig
config/server.ts controls the views server in the split views/API topology,
which is what both buddy dev and buddy serve run.
The views server renders stx pages and forwards everything else to the API
process. By default "everything else" means the /api/** prefix plus the
mutating verbs, which never match a page render:
export default {
proxy: {
prefixes: [],
paths: [],
},
} satisfies ServerConfig
That default leaves a plain GET route declared at the root on the API process
unreachable, because the views server treats it as a page and 404s. Name it here
and it is forwarded:
export default {
proxy: {
// Exact paths, forwarded whatever the verb.
paths: ['/health', '/me'],
// Prefixes, forwarded along with their whole subtree.
// `/api/` is always forwarded and does not need listing.
prefixes: ['/oauth/'],
// Verbs forwarded whatever the path. Setting this REPLACES the default
// of POST/PUT/PATCH/DELETE rather than adding to it.
// methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
} satisfies ServerConfig
buddy dev prints the effective rules at boot whenever you have widened them,
so a 404 on a route you know you registered is diagnosable.
One caveat before adding a path: stx runs its request hook before static file
serving, so a path listed here shadows a public/ file of the same name.
Listing /script.js makes public/script.js unreachable.
Why this is configuration rather than a route lookup: the route table is
registered in the API process, which is a separate process under buddy dev
and potentially a separate host in production, so the views server cannot
consult it.
Security headers on pages
Every response the views server renders carries the same three headers the API already sent:
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
None of them can be set from a template. X-Frame-Options is an HTTP header
with no <meta> equivalent, and CSP frame-ancestors is ignored when set
through <meta http-equiv>, so this has to come from the server.
An app that serves a deliberately embeddable page names it, and only
X-Frame-Options is dropped for those paths:
export default {
security: {
// An entry ending in `/` is a prefix. Anything else is an exact path.
embeddable: ['/embed/', '/share/card'],
},
} satisfies ServerConfig
Both buddy dev and buddy serve print the list at boot, because a page
another origin can frame is a deliberate exception worth seeing.
Two headers are deliberately not sent on pages:
Content-Security-Policy.STACKS_CSPhas only ever reached JSON API responses, and a blanket policy breaks inline stx script bootstrapping, Stripe iframes and OAuth popups. A page policy is worth having, but it needs its own testing rather than arriving as a side effect.Strict-Transport-Security.buddy servetreats itself as production even on a laptop, and HSTS onlocalhostcommits your browser to HTTPS for that host for a year. Terminate TLS at a proxy and set it there.
STACKS_SECURITY_HEADERS_DISABLE=true turns the whole set off, on both the API
and the views server, for a deployment behind a proxy that injects its own.
Environment Variables
Stacks uses a .env file for environment-specific configuration:
# .env
APP_NAME="My Stacks App"
APP_ENV=development
APP_KEY=base64:...
APP_DEBUG=true
APP_URL=http://localhost:3000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=stacks
DB_USERNAME=root
DB_PASSWORD=
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
Accessing Configuration
import { config } from '@stacksjs/config'
// Get a value
const appName = config('app.name')
const dbHost = config('database.connections.mysql.host')
// Get with default
const timezone = config('app.timezone', 'UTC')
// Check if config exists
if (config.has('services.stripe')) {
// Stripe is configured
}
// Get entire section
const mailConfig = config('mail')
Environment Helpers
import { env, isProduction, isDevelopment, isLocal } from '@stacksjs/env'
// Get environment variable
const apiKey = env('API_KEY')
const port = env('PORT', 3000) // with default
// Environment checks
if (isProduction()) {
// Production-only code
}
if (isDevelopment()) {
// Development-only code
}
// Custom environment check
if (env('APP_ENV') === 'staging') {
// Staging-only code
}
Remote Integration Environments
Any integration that transmits off the box - error reporting, analytics, log
shipping - is gated on APP_ENV, so a developer machine and a CI run do not
report into the same project as production. Without this, a local startup
failure arrives looking like a live incident.
Declare the environments permitted to transmit. There is one option, and it is
environments:
// config/analytics.ts
export default {
driver: 'analyticshq',
environments: ['production', 'staging'], // omit to get exactly this
drivers: {
analyticshq: { siteId: 'your-site-id' },
},
} satisfies AnalyticsConfig
Analytics is the adapter shipping this today. Others adopt the same option as they land, so the rules below are worth knowing once rather than per integration.
You never repeat env.APP_ENV in an integration. Stacks resolves the effective
label once, uses it to check the allowlist, and hands the same value to the SDK
as event metadata.
| Configuration | Result |
|---|---|
environments omitted | production and staging |
environments: [] | off everywhere, the explicit way to disable an integration without removing its credentials |
environments: ['production', 'staging', 'local'] | also reports from your machine, which is how you opt in deliberately |
enabled: false | off, always. It is checked first, so it is the last word |
enabled: true | does not bypass the allowlist |
APP_ENV unset or malformed | off |
An excluded environment never initializes the adapter at all. No upload client is constructed, no capture hook is registered, no tracking script is injected and no flush timer starts. Ordinary local console and file logging is unaffected, so you still see everything on your own machine.
prod, stage and dev are accepted as spellings of production, staging
and development. local, development and test are three different places
and are deliberately not treated as one, so allowing your machine does not also
allow CI.
The same rule applies in web and API servers, the dashboard, workers and
buddy commands, because each reads the same APP_ENV. Browser integrations
receive only the resolved public label, never server credentials. With the
AnalyticsHQ driver that label is the tag's data-environment attribute, which
the tracker attaches to every pageview, custom event and Web Vitals beacon, so
staging traffic is distinguishable without a second site id.
APP_ENV is a label, not an identity
A local command launched with APP_ENV=production qualifies for a production
allowlist. This prevents accidents; it is not a security boundary. Keep app IDs
and ingest keys configured per environment and per project so a mislabelled
process cannot write into a project it does not own.
Migrating an existing installation
Reporting behaviour does not change until an integration opts in. An adapter
without an environments option behaves exactly as it did before.
When you do adopt it, check what you are turning off rather than on. An
installation that has been reporting from developer machines stops doing so the
moment it takes the default allowlist, which is usually the point, but it is a
change in what you will see. If you depend on local reports, add local or
development explicitly instead of discovering the gap later.
Configuration Caching
Stacks does not ship a config cache command today. The only config-related CLI applies config-shape codemods when you upgrade:
# Apply config-shape codemods for the latest Stacks version
buddy config:migrate
Validation
Stacks validates your configuration at startup:
There is no config/validation.ts and no rules file. Validation is two
functions, called where booting should stop:
import { requireEnv, validateEnv } from '@stacksjs/env'
// Throws, naming every missing key at once rather than the first one.
requireEnv(['APP_KEY', 'DB_CONNECTION'])
// Checks declared enum values (APP_ENV, DB_CONNECTION, ...) and returns the
// problems rather than throwing, so a caller can decide what is fatal.
const errors = validateEnv()
if (errors.length > 0)
throw new Error(errors.join('\n'))
Beyond that, a config file is TypeScript: defineApp, defineAuth,
defineCache and the rest type it, so a wrong key is a compile error rather
than a startup one.
buddy env:check # validate the current .env
Best Practices
- Never commit secrets - Use
.envfor sensitive values - Use environment variables - Keep config files environment-agnostic
- Validate configuration - Catch errors early at startup
- Type your config - Use TypeScript for autocompletion and safety
Related
- Environment Setup - Setting up your environment
- Cloud Deployment - Cloud configuration
- Database - Database setup