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_CSP has 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 serve treats itself as production even on a laptop, and HSTS on localhost commits 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.

ConfigurationResult
environments omittedproduction 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: falseoff, always. It is checked first, so it is the last word
enabled: truedoes not bypass the allowlist
APP_ENV unset or malformedoff

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

  1. Never commit secrets - Use .env for sensitive values
  2. Use environment variables - Keep config files environment-agnostic
  3. Validate configuration - Catch errors early at startup
  4. Type your config - Use TypeScript for autocompletion and safety