Skip to content

Plugin Configuration Storage

Plugins need to store two types of data:

  1. Plugin-level configuration - Settings that control how the plugin behaves
  2. User data/entities - Content created and managed by users

This guide explains when to use ConfigService (for config) vs custom Drizzle schemas (for data).

Is this data used to configure/control plugin behavior?
├─ YES → Use ConfigService
│ Examples: Active queue provider, enabled auth strategies, plugin settings
└─ NO → Is this user-created content?
└─ YES → Use custom Drizzle schema
Examples: Health check instances, catalog systems, user-created items

ConfigService provides centralized, type-safe storage for plugin-level configuration with automatic secret encryption and schema migration support.

Use ConfigService when storing:

  • ✅ Plugin behavior settings (e.g., which queue provider to use)
  • ✅ Strategy/provider selections (e.g., active auth strategies)
  • ✅ Plugin-level toggles and preferences
  • Especially: Any config containing secrets (API keys, tokens, passwords)

Key indicator: The data controls how the plugin operates, not what content it manages.

import { coreServices, type ConfigService } from "@checkstack/backend-api";
import { z } from "zod";
// Schema for queue config
const queueConfigSchema = z.object({
pluginId: z.string(), // "memory", "bullmq", etc.
config: z.record(z.string(), z.unknown()),
});
export default createBackendPlugin({
pluginId: "backend",
register(env) {
env.registerInit({
deps: {
config: coreServices.config, // Inject ConfigService
queueManager: coreServices.queueManager,
},
init: async ({ config, queueManager }) => {
// Load active queue provider configuration
const queueConfig = await config.get(
"active",
queueConfigSchema,
1
);
if (queueConfig) {
await queueManager.setActiveBackend(
queueConfig.pluginId,
queueConfig.config
);
}
},
});
},
});

config.get(configId, schema, version, migrations?)

Load a configuration:

const strategy = await config.get(
"github",
githubStrategySchema,
1,
migrations
);
if (strategy?.enabled) {
// Use the strategy
}

config.getRedacted(configId, schema, version, migrations?)

Load configuration with secrets removed (safe for frontend):

const redacted = await config.getRedacted(
"github",
githubStrategySchema,
1
);
// redacted.clientSecret is undefined

config.set(configId, schema, version, data)

Save a configuration:

await config.set(
"github",
githubStrategySchema,
1,
{
clientId: "abc123",
clientSecret: "secret", // Automatically encrypted!
enabled: true,
}
);

config.delete(configId)

Delete a configuration:

await config.delete("github");

config.list()

List all configurations for this plugin:

const configs = await config.list();
// Returns: [{ configId: "github", updatedAt: Date }, ...]

ConfigService automatically encrypts/decrypts secrets marked with configString({ "x-secret": true }):

import { configString, configBoolean, configNumber } from "@checkstack/backend-api";
const githubStrategySchema = z.object({
clientId: configString({}),
clientSecret: configString({ "x-secret": true }), // Marked as secret
enabled: configBoolean({}),
});
// When you save:
await config.set("github", schema, 1, {
clientId: "abc123",
clientSecret: "my-secret", // Stored encrypted in database
enabled: true,
});
// When you load:
const strategy = await config.get("github", schema, 1);
// strategy.clientSecret = "my-secret" (decrypted automatically)
// When frontend loads:
const redacted = await config.getRedacted("github", schema, 1);
// redacted.clientSecret is undefined (removed)

If a secret field cannot be decrypted (wrong or missing key after rotation, or tampered ciphertext), get() fails closed: it throws a typed DecryptionError and logs the failure with the config key and plugin id (never the secret or ciphertext). It does NOT silently return the ciphertext in place of the plaintext. See Setting up secret encryption for key rotation and the re-encrypt command.

ConfigService supports schema migrations (see Versioned Configurations):

const migrations = [
{
fromVersion: 1,
toVersion: 2,
migrate: (data: V1) => ({ ...data, newField: "default" }),
},
];
// Old configs are automatically migrated when loaded
const config = await configService.get("github", schemaV2, 2, migrations);

Use custom Drizzle schemas when storing:

  • ✅ User-created entities (health checks, catalog systems, etc.)
  • ✅ User content (incidents, maintenance windows, etc.)
  • ✅ Relational data with foreign keys
  • ✅ Data that users CRUD via the UI
  • ✅ Historical data (health check runs, audit logs)

Key indicator: The data is content managed by users, not settings that control the plugin.

src/schema.ts
export const healthCheckConfigurations = pgTable("health_check_configurations", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
strategyId: text("strategy_id").notNull(), // "http", "ping", etc.
config: jsonb("config").notNull(),
intervalSeconds: integer("interval_seconds").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
// These are USER-CREATED health check instances
// Examples: "Check example.com homepage", "Ping database server"
// NOT plugin configuration

ConfigService is optimized for:

  • Plugin-level settings (one active config per plugin)
  • Automatic secret encryption
  • Schema migrations

But entities need:

  • Many instances per plugin (hundreds of health checks)
  • Complex foreign key relationships
  • Efficient querying and pagination
  • User-friendly CRUD operations

Custom schemas are the right choice for user data.

AspectConfigServiceCustom Drizzle Schema
PurposePlugin behavior settingsUser-created content
ScopePlugin-levelUser-level
CardinalityOne or few per pluginMany instances
SecretsAutomatic encryptionManual if needed
MigrationsBuilt-in versioningDrizzle migrations
QueriesSimple get/set by IDComplex SQL queries
UISettings pagesCRUD interfaces
ExamplesQueue provider, auth strategiesHealth checks, systems, users

Auth Backend - Strategy configurations:

import { configString } from "@checkstack/backend-api";
// Stores: "Which auth strategies are enabled?"
await config.set("github", githubSchema, 1, {
clientId: "...",
clientSecret: "...", // Auto-encrypted via configString({ "x-secret": true })
enabled: true,
});

Queue Backend - Active provider:

// Stores: "Which queue provider is active?"
await config.set("active", queueSchema, 1, {
pluginId: "bullmq",
config: { redis: { host: "localhost" } },
});

Auth Backend - Platform Registration Settings:

// Controls whether new user registration is allowed platform-wide.
// When disabled, only existing users can sign in - useful for private deployments.
const platformRegistrationConfigV1 = z.object({
allowRegistration: z
.boolean()
.default(true)
.describe(
"When enabled, new users can create accounts. When disabled, only existing users can sign in."
),
});
// The schema's describe() is automatically shown in DynamicForm settings UI
await config.set("platform.registration", platformRegistrationConfigV1, 1, {
allowRegistration: false, // Lock down registration
});

Health Check Backend - Check instances:

// Many user-created health checks
export const healthCheckConfigurations = pgTable(/* ... */);
// Examples: "API health", "DB ping", "Homepage check"

Catalog Backend - Systems and groups:

// User-managed catalog entities
export const systems = pgTable(/* ... */);
export const groups = pgTable(/* ... */);

If you’re currently using custom tables for plugin config, migrate to ConfigService:

// ❌ Old: Custom table for plugin config
export const authStrategy = pgTable("auth_strategy", {
id: text("id").primaryKey(),
enabled: boolean("enabled"),
config: jsonb("config"),
});
// Manual encryption/decryption required
import { configString } from "@checkstack/backend-api";
// ✅ New: Use ConfigService
await config.set("github", schema, 1, {
clientId: "...",
clientSecret: "...", // Auto-encrypted via configString({ "x-secret": true })
enabled: true,
});
// Drop old table in migration
DROP TABLE IF EXISTS "auth_strategy";
// ✅ Good: Plugin behavior config
await config.set("active-theme", themeSchema, 1, { theme: "dark" });
// ✅ Good: User-created entities
await db.insert(healthChecks).values({
name: "API Health Check",
url: "https://api.example.com",
});
import { configString } from "@checkstack/backend-api";
// ✅ Good: Explicit secret marking
const schema = z.object({
apiKey: configString({ "x-secret": true }),
apiUrl: configString({}).url(),
});

4. Don’t Store Secrets in Custom Schemas

Section titled “4. Don’t Store Secrets in Custom Schemas”
// ❌ Bad: Storing secrets without encryption
export const integrations = pgTable("integrations", {
apiKey: text("api_key"), // Not encrypted!
});
// ✅ Good: Use ConfigService for secrets
const schema = z.object({
webhookUrl: configString({ "x-secret": true }),
});
await config.set("slack-integration", schema, 1, {
webhookUrl: "https://...", // Auto-encrypted
});