concile
Reference

Configuration

The full v.* validator catalog, concile.config.ts, crons.ts, http.ts, and every environment variable the engine reads.

This page is your complete guide to all the configuration options available in concile. We will cover the v.* validator catalog, the schema builder, concile.config.ts, crons.ts, http.ts, and all the CONCILE_* environment variables that the CLI looks for. If you want to dive into the reasoning behind any of these features, you can check out Schema & tables, Queries, and the Components overview.

Validators (v)

You can bring these in from @concile/values. All the fields in defineTable, every entry in your function's args or returns, and the configuration for composed components in concile.config.ts are built using these validators.

When you use Infer<typeof validator>, you get the matching TypeScript type. You can also serialize any validator into JSON by calling .toJSON(). The engine uses that JSON to store the live schema for a table, and it is exactly what concile deploy looks at when comparing schema differences.

Prop

Type

concile/schema.ts
import { v } from "@concile/values";

v.object({
  status: v.union(v.literal("open"), v.literal("closed")),
  assigneeId: v.optional(v.id("users")),
  tags: v.array(v.string()),
  translations: v.record(v.string(), v.string()),
  raw: v.any(),
});

Every time you write data, whether you are using ctx.db.insert, replace, or running a patch, the system checks it against the document validator for that table right before committing the transaction. If you try to save a document with the wrong type, a missing required field, or an extra field that shouldn't be there, the system will reject it with a DocumentValidationError before anything is saved, and the mutation will throw an error. There are no partial commits here.

If you want to turn off validation for an entire schema, you can use defineSchema(tables, { schemaValidation: false }). This stops checking every table, and you won't be able to turn it back on for individual tables. If you just need a quick workaround for a specific field, it is much better to loosen that one field by using v.any().

You can check out Schema & tables to see exactly what the error messages look like and to learn more about the underlying guarantee, which is that the same validator is recreated from JSON.

Schema builder

concile/schema.ts
import { defineSchema, defineTable, v } from "@concile/values";

export default defineSchema({
  conversations: defineTable({
    title: v.string(),
  }),
  messages: defineTable({
    conversationId: v.id("conversations"),
    author: v.string(),
    body: v.string(),
  })
    .index("by_conversation", ["conversationId"])
    .shardKey("conversationId"),
});

Prop

Type

Even if you do not add any .index(...) calls yourself, every table gets a built-in by_creation index automatically. It sorts rows based on when they were created, meaning you can always use ctx.db.query(table, "by_creation").collect() without writing any schema definitions.

System fields

Every document across all your tables automatically includes two special fields that you never need to set yourself:

Prop

Type

You might find it interesting that every index key implicitly ends with _creationTime and then _id. This clever design is what guarantees that index keys stay unique and pagination cursors remain stable, even if you have duplicate field values.

System tables

The _storage table is the one built-in system table that lives in your app namespace. Every project gets this automatically to hold metadata for file storage, regardless of whether your app actually uses ctx.storage. Here is what its document shape looks like:

{
  status: v.union(v.literal("pending"), v.literal("ready")),
  key: v.string(),
  size: v.union(v.number(), v.null()),
  contentType: v.union(v.string(), v.null()),
  sha256: v.union(v.string(), v.null()),
  visibility: v.union(v.literal("private"), v.literal("public")),
  expiresAt: v.union(v.number(), v.null()),
}

When you use Id<"_storage"> or v.id("_storage") in your schema.ts, they type-check exactly like references to tables you created yourself. The codegen tool seamlessly merges system tables into DataModel alongside your own tables. You can learn more about the ctx.storage API that relies on this table by reading File storage.

On the other hand, tables that belong to composed components, like jobs and crons from the scheduler or users and sessions from auth, are namespaced with the component name instead of sitting at the app root. They do not appear in your schema.ts file. For details on these, you can look at each component's reference page in the Components section.

concile.config.ts

concile.config.ts is an optional file you can use to add components to your project. You should place it alongside your concile/ directory, so it acts as a sibling rather than living inside it.

If you decide not to include this file at all, your project just won't have any components, which is exactly the same as explicitly setting { components: [] }. When you run commands like concile dev, serve, build, or deploy, they all look for this same file, whether you name it concile.config.ts or .js, right in your project root.

concile.config.ts
import { defineConfig } from "@concile/component";
import { defineScheduler } from "@concile/scheduler";
import { defineWorkflow } from "@concile/workflow";
import { defineAuth } from "@concile/auth";
import { defineAuthz } from "@concile/authz";
import { defineTriggers } from "@concile/triggers";
import { defineNotifications, resendEmail } from "@concile/notifications";
import crons from "./concile/crons";
import { policies } from "./concile/policies";

export default defineConfig({
  components: [
    defineScheduler({ crons }),
    defineWorkflow({ workflows: {} }),
    defineAuth(),
    defineAuthz({ policies }), // requires: ["auth"], meaning auth must also be composed
    defineTriggers({ messages: { handler: "logChange" } }),
    defineNotifications({
      channels: {
        email: { provider: resendEmail({ apiKey: process.env.RESEND_API_KEY! }), from: "noreply@example.com" },
      },
    }),
  ],
});

The ConcileConfig object has three fields, where one is required and the other two are optional:

export interface ConcileConfig {
  components: ComponentDefinition[];
  deploy?: DeployConfig;
  functionsDir?: string;
}

export function defineConfig(config: ConcileConfig): ConcileConfig; // this just returns the same config to give the file a typed default export

You can use functionsDir to change where your schema and functions are located relative to the project root, which defaults to "concile". If you use an explicit --dir flag on any command, that flag will override this setting, which in turn overrides the default. Feel free to check out the CLI page to learn more about how --dir precedence works.

Each entry in your components array is simply a call to a component's own define*() function, like defineScheduler(), defineWorkflow(), defineAuth(), defineAuthz(), defineTriggers(), or defineNotifications(). Every one of these functions just returns a standard ComponentDefinition.

The deploy block

The deploy block contains the target configuration for concile deploy. Because this is built right in, you don't need to pass any extra flags when you run the deploy command:

export interface DeployConfig {
  /** The system uses this when you omit the --target flag. The effective default is "serve". */
  defaultTarget?: string;
  /** These are keyed by target name, matching the --target value. */
  targets?: Record<string, TargetConfig>;
}

export interface TargetConfig {
  /** You can select your deploy adapter from options like "serve", "cloudflare", "docker", "railway", "fly", or "aws". */
  provider: string;
  /** These are environment-specific overrides that get merged over your shared settings, chosen by the --env flag. */
  environments?: Record<string, Record<string, unknown>>;
  /** Any other fields you include are considered provider-specific settings, like a url, adminKey, or serviceArn. */
  [k: string]: unknown;
}

The env() helper, which you also import from @concile/component, lets you read an environment variable while the config loads. This is a great way to pass secrets into your target settings without hardcoding them. It is designed to never throw an error. If a variable is unset and has no fallback, it just resolves to "". The deployment target's own preflight checks will fail fast if a critical credential is truly missing, meaning your config can still successfully load even if you do not have a .env file handy.

concile.config.ts
import { defineConfig, env } from "@concile/component";

export default defineConfig({
  components: [],
  deploy: {
    defaultTarget: "serve",
    targets: {
      serve: {
        provider: "serve",
        url: env("CONCILE_DEPLOY_URL", "https://my-deployment.example.com"),
        adminKey: env("CONCILE_ADMIN_KEY"),
      },
      cloudflare: { provider: "cloudflare" },
    },
  },
});

You can look up concile deploy for more details on the command line flags that interact with this block. You should also review Deploy & build to learn about the specific settings available for each target.

For more information on the options available for each component's define*() function, like defineAuth(options?), defineAuthz(config), defineWorkflow(opts), defineTriggers(opts), defineNotifications(opts), or defineScheduler(opts?), take a look at their individual pages under Components.

Composition rules:

  • Declaration order does not have to match dependency order. When a component has dependencies, it declares them using requires: string[]. For example, defineAuthz is built with requires: ["auth"]. Before booting up, composeComponents runs a stable topological sort over your entire list, ensuring every component is initialized after the things it requires, no matter what order you put them in the array. If you forget to include a required component, like trying to use defineAuthz() without defineAuth(), the system will fail fast during composition and let you know that component "authz" requires "auth", which is not enabled. It will also reject any circular dependencies.
  • The composed set is locked in at boot time. The engine decides which components are active and assigns their table numbers just once, right when concile dev, serve, or build starts. If you want to add or remove a component from concile.config.ts, you will need to restart the process. This is not something that concile deploy can swap out live. The deploy command only hot-swaps your functions and additive schema changes against the set of components that are already running. You can read more about this in Deploy and build.
  • Each component's configuration is just a plain value. You pass these values directly to the component's define*() function. Concile does not own or manage these fields. This means that provider API keys, OAuth client credentials, and other secrets are read from process.env directly by your own concile.config.ts file, rather than by the engine itself. We cover this in more detail in the Component config from environment variables section down below.

crons.ts

crons.ts is the typical file you use to set up recurring or scheduled tasks for your app. As we showed earlier, it gets pulled into your project when you use defineScheduler({ crons }). Since cronJobs() and its registry come directly from @concile/scheduler and are re-exported through the codegen's _generated/server, you only need a single import to get started:

concile/crons.ts
import { cronJobs } from "./_generated/server";
import { internal } from "./_generated/api";

const crons = cronJobs();

crons.interval("cleanup", { minutes: 5 }, internal.maintenance.purge, {});
crons.cron("nightly", "0 3 * * *", internal.reports.build, {}, { tz: "America/New_York" });
crons.daily("digest", { hourUTC: 8, minuteUTC: 0 }, internal.email.digest, {});
crons.hourly("rollup", { minuteUTC: 15 }, internal.stats.rollup, {});
crons.weekly("report", { dayOfWeek: "monday", hourUTC: 9, minuteUTC: 0 }, internal.reports.weekly, {});
crons.monthly("invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice, {});

export default crons;

Then, back in your concile.config.ts, you just import it and use it like this: import crons from "./concile/crons"; defineScheduler({ crons }).

Prop

Type

For every one of these methods, fnRef needs to be an internal.* or api.* function reference, and args is just the JSON arguments object for that function. This is the exact same format you would use with ctx.scheduler.runAfter or runAt. Be sure to give each cron a unique name. If you accidentally try to register the same name twice, the system will throw an error right when it loads.

If you are using .interval or .cron, you can pass a CronOpts object that looks like { tz?: string; catchUp?: "skip" | "fireOnce" | "fireAll" }. The other methods, like .daily, .hourly, .weekly, and .monthly, only accept the { catchUp? } option. They don't take a tz override because their scheduling fields are already strictly defined in UTC, so adding a timezone could make it confusing to know which field it applies to.

The catchUp setting determines what happens if your application was down and missed some scheduled runs. By default, it is set to "skip", which means any missed runs are simply dropped. If you choose "fireOnce", it will execute just the single most recent occurrence you missed. If you select "fireAll", it will replay all of the missed occurrences in chronological order.

If you edit a cron's timing, function, or arguments in concile.config.ts and deploy those changes, its schedule will automatically restart from that exact moment. However, if you only update the catchUp setting, the running schedule stays exactly as it is. If you remove an entry entirely from crons.ts, the system will cancel its upcoming task on the next boot or reconcile cycle, and it will remove the database row. Any actual work jobs that were already added to the queue are left alone to finish processing.

http.ts

http.ts is the standard file where you set up an httpRouter() filled with your routes, each powered by an httpAction, and then export it as the default.

concile/http.ts
import { httpRouter } from "./_generated/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";

const http = httpRouter();

http.route({
  path: "/webhooks/stripe",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const event = await request.json();
    await ctx.runMutation(internal.billing.recordEvent, { event });
    return new Response(null, { status: 200 });
  }),
});

http.route({
  pathPrefix: "/files/",
  method: "GET",
  handler: httpAction(async (ctx, request) => { /* ... */ return new Response("ok"); }),
});

export default http;

Prop

Type

Matching. The HTTP method always has to be an exact match. If you have an exact path match, it will take priority over any pathPrefix. When multiple pathPrefix rules match, the longest one wins.

Any path starting with /api/ or where the first segment starts with an underscore, like /_admin, is strictly reserved for the engine itself. If you try to register one of these with route(), it will fail immediately. This prevents a misconfigured http.ts file from accidentally shadowing important built-in endpoints like the file storage routes or the deploy targets.

An httpAction handler works exactly like a regular action, meaning it follows the same rules about running in a non-deterministic context. You have full access to ctx.runQuery, ctx.runMutation, ctx.runAction, native fetch, Date, and timers, but you cannot use ctx.db directly. The only real difference is how it handles inputs and outputs. Instead of taking JSON arguments and returning a JSON value, it receives a raw Request object and expects you to return a raw Response. You can read more about this shared execution model in the Actions guide.

Just to be clear, there is no automatic CORS handling, no support for named path parameters like :id, and no way to add per-route middleware. These features were deliberately excluded, rather than just being saved for later.

Environment variables

Every variable listed below starts with CONCILE_ and is read by the CLI, whether you are running concile dev, serve, or deploy. If there is a CLI flag for the exact same thing, the flag will always take priority over its environment variable equivalent.

Core

VariableRead byMeaning
CONCILE_ADMIN_KEYserve, dev (optional)This is the bearer token used for the dashboard and all admin or deploy endpoints. It is required when running serve, and the process will fail fast if it is missing or blank. If you run dev and it is unset, the system will generate a temporary one for you and print it to the console, or warn you if it is set but blank.
PORTserveThis sets your HTTP and WebSocket port, defaulting to 3000. You might notice it does not have the CONCILE_ prefix, as PORT is just the standard variable used by most platforms. Note that dev only accepts the --port flag and ignores this environment variable entirely.
CONCILE_DATA_DIRserveThis specifies the directory where your SQLite data file will be saved, specifically at <dir>/db.sqlite. Keep in mind that dev uses --data <path> instead, which defaults to .concile/data.db, and does not look at this variable.
CONCILE_DATABASE_URLdev, serve, compiled build binaries, fleet reshardUse this to provide your Postgres connection string. If you leave it unset, the engine will fall back to SQLite. Using the --database-url flag will override this value. Check out the Postgres guide for more details.
CONCILE_PG_STREAMany process on the Postgres backendSetting this to 0 or false will turn off streaming index scans in the Postgres adapter, meaning it will fall back to buffered reads while still giving you the exact same results. Leaving it unset or providing any other value keeps streaming turned on. More details are available in the Postgres documentation.
CONCILE_DASHBOARDserveYou can set this to off if you want to completely disable the dashboard, which does the exact same thing as the --no-dashboard flag. Any other value, including leaving it blank, will leave the dashboard enabled.
CONCILE_ALLOW_DEPLOYserveSetting this to 1 will enable the POST /_admin/deploy endpoint, which is the target for hot-swapping your code with concile deploy. It is turned off by default.
CONCILE_WEB_DIRserveThis points to a static web UI directory to serve right at your site root. It runs alongside the sync WebSocket and API, meaning a single-origin client app won't need a separate configuration for its backend URL. It does the same thing as the --web flag.
CONCILE_DEPLOY_URLdeployThis is the default target URL that concile deploy will use if you do not explicitly provide the --url flag.

File storage

VariableMeaning
CONCILE_STORAGE_BUCKETThis lets you select an S3-compatible backend. If you provide any bucket name here, the system will switch from using the local filesystem to using S3. If you leave it unset, your files will just live on disk under <data dir>/storage without any setup needed.
CONCILE_STORAGE_ENDPOINTHere you provide the endpoint URL for your S3-compatible service, like MinIO or Cloudflare R2. This only does something if you also set CONCILE_STORAGE_BUCKET. To save you from confusion, serve and dev will fail fast if you try to configure an endpoint, region, or public URL without specifying a bucket, instead of silently falling back to the filesystem.
CONCILE_STORAGE_REGIONYour S3 region.
CONCILE_STORAGE_PUBLIC_URLThis is the public base URL that will be used when you call getUrl() on files with "public" visibility, pointing directly to your CDN or public bucket endpoint rather than generating a private token URL.
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEYThese are the standard AWS credentials for the S3 backend. We deliberately do not treat simply having these set as an intent to use S3, because they are often hanging around in environments for other reasons. Only setting CONCILE_STORAGE_BUCKET actually turns on the backend.

If you decide to use the --storage-bucket or --storage-endpoint flags while running dev or serve, those will override whatever you put in your environment variables. Read more about this in the File storage section.

Fleet (Tier 2: Postgres multi-writer)

You can opt into a symmetric fleet of serve processes that all share a single Postgres database by using --fleet or setting CONCILE_FLEET=1 or true. Just remember that this requires the enterprise @concile/fleet package. You can find out more in the Scaling documentation.

VariableMeaning
CONCILE_FLEETSetting this to 1 or true turns on fleet mode, just like the --fleet flag. You will also need to provide a CONCILE_DATABASE_URL and an advertise URL. If either of those is missing, or if you haven't installed @concile/fleet, the boot process will fail immediately with a clear error message.
CONCILE_ADVERTISE_URLThis is the URL that other fleet nodes will use to reach this specific node. It gets recorded on the write lease when this node is the active writer, and sync nodes will forward their writes and proxy their HTTP actions here. This is exactly like using the --advertise-url flag and is absolutely required when running in fleet mode.
CONCILE_FLEET_SHARDSThis determines how many shards your fleet will run, defaulting to 8 in the @concile/fleet package. The system persists this number on the very first boot and it cannot be changed later. If you try to change it on a subsequent run, the boot will fail. If you aren't using fleet mode, this variable also sets the lane count for a single-node --object-store --shards N writer setup.
CONCILE_FLEET_LEASE_TTL_MSThis controls the time-to-live for the write lease in milliseconds. It acts as the primary dial that scales the entire failover timing system, including heartbeats and acquire cadences. If you leave it unset, the fleet defaults to 15000ms. It is meant as an operations tuning knob, so there is no equivalent CLI flag.
CONCILE_FLEET_MULTI_WRITERBy setting this to 1, true, or yes, you opt into multi-writer scale-out, which shards writes across multiple writer nodes that all point to the same Postgres database. It is turned off by default.
CONCILE_GROUP_COMMITA value of 1, true, or yes will force group commit to stay on, meaning the system will batch concurrent commits through a two-buffer committer and amortize a single fsync across all of them. Providing any other explicit value will force it off. If you leave it unset, the default depends on your setup: it stays on for single-node Postgres, and stays off for both SQLite and fleet nodes.

Object storage (Tier 3: bucket-backed, no database)

You can choose to run the engine directly against an object-storage bucket or local directory, using that as the foundational layer instead of SQLite or Postgres. To do this, use --object-store <url> or the CONCILE_OBJECT_STORE variable. Keep in mind that this is mutually exclusive with --fleet. Learn more in the Scaling guide.

VariableMeaning
CONCILE_OBJECT_STOREProvide your bucket or directory URL here, like s3://..., s3+http(s)://..., file://..., or even just a raw file path. This does the exact same thing as the --object-store flag.
CONCILE_OBJECTSTORE_GC_MSThis sets how often the object-store writer runs its garbage-collection sweep, measured in milliseconds. Leaving it unset gives you a default of around 60 seconds. This is an environment-only setting with no CLI flag, similar to how CONCILE_FLEET_LEASE_TTL_MS works for ops tuning.
CONCILE_REPLICASetting this to 1, true, or yes will boot the node as a completely read-only replica of the object store's shard. It will materialize data and tail the feed, but it will never try to grab a write lease, and it will reject all mutations. You must also have --object-store or CONCILE_OBJECT_STORE set for this to work. It acts the same as the --replica flag.
CONCILE_WRITER_URLThis is only relevant if you are booting a --replica. It specifies the URL of the active writer node. When you provide this, any mutation or action hitting the replica will simply be forwarded to the writer instead of being outright rejected. This mirrors the behavior of the --writer-url flag.

The --shards N flag, which corresponds to the shared CONCILE_FLEET_SHARDS variable we talked about earlier, determines how many object-storage lanes a writer is responsible for when N > 1. You cannot use this in combination with --replica, since a replica is single-shard by definition, or with --fleet, which has its own shard resolution logic.

The wake seam (serverless / stop-between-requests hosts)

This section is for hosting environments that stop the process entirely between requests, like Cloudflare Containers. In those cases, a standard setTimeout will never actually fire, meaning all of your scheduler, trigger, and reaper drivers would completely freeze.

VariableMeaning
CONCILE_WAKE_URLThis is an HTTP endpoint where serve will POST the absolute timestamp of when the next wake needs to happen. For example, on Cloudflare, this would be the container's Outbound-Worker hostname, turning it into a Durable Object alarm. It does the same thing as the --wake-url flag. If you leave it unset, which is what you would do for any normal deployment, the system just uses a plain setTimeout and behaves exactly as you would expect.
CONCILE_BACKSTOP_MIN_MSThis applies a firm minimum limit to the backstop polling cadence for every driver. If left unset, each driver just uses its normal 30 to 60 second default. You will want to set this in environments where every wake-up causes a cold start, ensuring a 30-second backstop doesn't force a container to boot up every half minute forever. This is the equivalent of the --backstop-min-ms flag.

Cloudflare DO-native host

VariableMeaning
CONCILE_DO_LOCATION_HINTThis pins the single-shard Cloudflare Durable Object host's ONE Durable Object to a specific home region. You can use any of the 11 location hint codes, such as wnam, enam, sam, weur, eeur, apac, apac-ne, apac-se, oc, afr, or me. If you leave it unset, Cloudflare will simply place the Durable Object near wherever the very first request comes from, which is its normal behavior. Keep in mind that only the very first get() call ever pays attention to this hint, as a Durable Object stays single-homed in that region for its entire life once it gets created.

As a quick note, the durable_objects binding name itself, like CONCILE_DO in your wrangler.jsonc file, is just a Wrangler binding identifier. The engine does not actually read it from process.env. Check out the Cloudflare documentation to see the full setup for Workers deployments.

Component config from environment variables

Concile itself doesn't actually manage any environment variables for component secrets. Provider API keys, OAuth client credentials, and other sensitive values are simply plain fields on each component's define*() options. It is entirely up to your own concile.config.ts file to populate them from process.env however you see fit:

concile.config.ts
import { defineConfig } from "@concile/component";
import { defineAuth } from "@concile/auth";
import { googleProvider, githubProvider } from "@concile/auth";
import { defineNotifications, resendEmail, twilioSms } from "@concile/notifications";

export default defineConfig({
  components: [
    defineAuth({
      oauth: {
        providers: [
          googleProvider({
            clientId: process.env.GOOGLE_CLIENT_ID!,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
          }),
          githubProvider({
            clientId: process.env.GITHUB_CLIENT_ID!,
            clientSecret: process.env.GITHUB_CLIENT_SECRET!,
          }),
        ],
      },
    }),
    defineNotifications({
      channels: {
        email: { provider: resendEmail({ apiKey: process.env.RESEND_API_KEY! }), from: "noreply@example.com" },
        sms: {
          provider: twilioSms({
            accountSid: process.env.TWILIO_ACCOUNT_SID!,
            authToken: process.env.TWILIO_AUTH_TOKEN!,
            from: process.env.TWILIO_FROM_NUMBER!,
          }),
        },
      },
    }),
  ],
});

The specific variable names used here, like GOOGLE_CLIENT_ID, RESEND_API_KEY, or TWILIO_ACCOUNT_SID, are completely your choice. They are not a strict contract that the engine enforces or attempts to read on its own. You can pick whatever names make the most sense for how you handle secrets in your deployment, whether that means using a Docker Compose env_file, a dedicated platform secrets manager, or just a simple .env file that gets loaded before concile serve boots up. Be sure to look at the Auth, Notifications, and Authorization guides to see the complete provider and configuration options for each component.

See also

  • Schema & tables: an explanation of the core concepts and guarantees that these validators and the schema builder provide.
  • Queries: a look at how an index you declare here actually influences what gets read from the database.
  • Components overview: the definitive reference for every component's define*() function.
  • CLI: a complete list of every command and flag that interacts with concile.config.ts and the environment variables we discussed.
  • Scaling and Cloudflare: deep dives into the fleet, object-storage, and DO-native deployment models that rely on those multi-node variables.
  • Self-hosting and Postgres: step-by-step walkthroughs for configuring your core deployment.

On this page