Configuration reference
The CMS reads 54 environment variables, and this page gives each one's meaning, its default, when you need it and what breaks when it is wrong.
The CMS reads its configuration from the process environment and from nothing else: no file, no database setting, no dashboard screen. It validates its variables once, at boot, before it opens a port. When one is missing or malformed it refuses to start and names every offending variable in one message, so you fix a bad deployment in one pass rather than one restart per mistake:
ConfigError: Invalid configuration:
SESSION_SECRET: must be at least 32 characters
TRUST_PROXY: Invalid input: expected number, received NaN
WEBHOOK_SECRET_KEY: required: 32 bytes, base64 (openssl rand -base64 32)
The one exception is JSON_BODY_LIMIT, which is checked only for being non-empty; its row under Body and import limits says what a bad value does instead.
Five variables have no default and are always required: MONGO_URI, REDIS_URL, SESSION_SECRET, BASE_URL and WEBHOOK_SECRET_KEY. Everything else has a default that suits a single instance, and you set it only to change that.
A few spellings recur in the tables below:
- Switches are spelled exactly as the table says, and nothing else is accepted:
0or1for the webhook switches,onorofffor the sign-in providers,trueorfalseforASSET_S3_FORCE_PATH_STYLE,openorclosedfor registration.yesis a boot failure, not a yes. - Sizes use the
bytesspellings:25mb,512kb,5gb, or a plain number of bytes. nginx's25mis not one of them. - Windows are in milliseconds:
60000is one minute. - Lists are comma-separated, with no spaces needed.
Some variables are read only when another one switches them on — the provider credentials under AUTH_GITHUB=on or AUTH_GOOGLE=on, the bucket settings under ASSET_BACKEND=s3. Under the other value they are ignored, not refused, so an environment can keep them filled in while the switch is off.
Server
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
PORT |
The port the process listens on. The dashboard, /api/v1, /d/v1, /healthz and /readyz are all served from it. |
3000 (the image sets 3000 too) |
Only to listen elsewhere; the Helm chart's container always listens on 3000. | Not a positive whole number: the CMS refuses to start. A port your proxy, port mapping or probes do not expect: nothing answers there, the image's own health check, which asks port 3000, marks the container unhealthy, and probes aimed at 3000 fail. |
DASHBOARD_DIST |
The dashboard's build, which the API serves at / from the same origin, so the session cookie needs no CORS. A relative path is resolved against the API's own directory. |
../dashboard/dist; the image sets /app/apps/dashboard/dist |
Never with the image. Only when you run the CMS from its source with the dashboard built somewhere else. | A directory that does not exist is not an error: the CMS serves the APIs only, logs dashboard.disabled: no dashboard build at … once at boot, and content managers find no dashboard at /. |
SHUTDOWN_GRACE_MS |
How long a shutdown waits for requests already in flight before closing anyway. It also caps how long a reader waits for an image variant another replica is making: the grace period less two seconds, at most 20 seconds. | 10000 |
When the orchestrator's stop timeout is not comfortably longer, as Docker's own default of 10 seconds is not, or when you serve very large images whose variants take longer than 8 seconds to encode. | Longer than the orchestrator's own grace period: the SIGKILL lands mid-drain and in-flight requests are cut off. Too short: slow requests are dropped at every deploy, and readers waiting for a large variant get a 503 sooner. |
Data stores
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
MONGO_URI |
The Mongo connection string, mongodb:// or mongodb+srv://. It must address a replica set: the CMS writes a project's creation and a CLI push in one transaction, and transactions need one. A single-node set is enough. |
None | Always. | Missing or not a URL: the CMS refuses to start. A standalone mongod: every write that needs a transaction fails, creating a project and every quillet push among them. Unreachable: /readyz answers 503 with "mongo":"down" and requests fail. |
REDIS_URL |
The Redis connection string. Redis holds sessions, rate-limit counters, the delivery cache and the image-transform locks. | None | Always. | Missing or not a URL: the CMS refuses to start. Unreachable: /readyz answers 503 with "redis":"down", nobody can sign in, and every rate-limited request is refused with 503 rather than admitted uncounted. |
REDIS_KEY_PREFIX |
What every Redis key the CMS writes begins with: sessions, counters, cache and locks together. | q: |
Only to share one Redis between two installations that must not see each other. | Two installations under the same prefix on one Redis behave as one: each clears the other's delivery cache and shares its sessions and rate limits. Changed on a running instance: everyone is signed out and the cache starts cold. Empty: the CMS refuses to start. |
Sessions and URL
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
SESSION_SECRET |
Signs the session cookie and the short-lived cookie of a GitHub or Google sign-in. Unless ASSET_SIGNING_SECRET is set, the key that signs private files' links is derived from it too. Generate one with openssl rand -base64 48. |
None | Always. | Shorter than 32 characters or missing: the CMS refuses to start. Changed: every session ends and everyone signs in again; without ASSET_SIGNING_SECRET, every outstanding private-file link stops working too. A value copied from an example: anyone who has read the example knows your secret. |
BASE_URL |
Where this deployment is reachable, as the browser sees it. The CMS builds absolute URLs from it: the sign-in callbacks, the asset URLs it hands out when ASSET_PUBLIC_URL is empty, and the address in its boot log line. An https:// value also marks the session cookie Secure. |
None | Always. | Missing or not a URL: the CMS refuses to start. A host other than the real one: GitHub and Google refuse the callback, and image and file URLs in delivered content point at the wrong host. https:// while you browse over plain http://: the browser drops the Secure cookie, so signing in appears to work and lands you back on the sign-in page. |
Logging
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
LOG_LEVEL |
How much the log writes: debug, info, warn or error. The log is one JSON object per line on standard output. |
info |
When you are investigating something (debug) or want less noise (warn). |
Any other word: the CMS refuses to start. error: refused provider sign-ins and other warn lines vanish, so a misconfigured provider fails silently. debug in production: the framework's own boot chatter and much more fill the log. |
Proxy and rate limits
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
TRUST_PROXY |
How many proxies sit in front of the CMS. It counts back this many hops through X-Forwarded-For to find the caller's address, which every per-address rate limit is counted against. A hop count, never true. |
0; the Helm chart sets 1 |
Whenever an ingress, load balancer or reverse proxy forwards traffic to the CMS: 1 for one hop. |
0 behind a proxy: every caller looks like the proxy and shares one budget, so one person's mistyped passwords lock everyone out of signing in. Higher than the real number of hops: a caller writes its own X-Forwarded-For and gets a fresh budget on every request. true: the CMS refuses to start. |
AUTH_RATE_LIMIT |
Sign-in, registration and provider sign-in attempts allowed per address per window. Each of those routes counts its own budget. | 10 |
To loosen it for a team behind one office address, or tighten it. | Too low: people behind a shared address get 429 after a few attempts. Too high: password guessing gets more tries per minute. Zero, negative or not a number: the CMS refuses to start. |
AUTH_RATE_LIMIT_WINDOW_MS |
The length of that window. | 60000 |
When you change the window rather than the count. | Too long: a locked-out person waits that long before trying again. Too short: the limit barely slows guessing. Not a positive number: the CMS refuses to start. |
Registration and sign-in
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
AUTH_REGISTRATION |
Whether the password sign-up form is open or closed. Closed turns POST /api/v1/auth/register off for everyone and hides the form; existing accounts keep working, and a provider below still creates accounts for its members. |
open |
Set closed once the first accounts exist on any instance strangers can reach. |
open on a public instance: anyone who finds the URL can create an account and their own projects. closed before anyone has an account and with no provider on: nobody can get in. Any other word: the CMS refuses to start. |
AUTH_GITHUB |
Sign in with GitHub, on or off. Under on the three variables below are required; under off they are ignored. |
off |
When people should sign in with GitHub. | on without a client id, a secret or an organisation: the CMS refuses to start, naming each missing one. true or 1: the CMS refuses to start. |
AUTH_GITHUB_CLIENT_ID |
The client id of the OAuth App you registered on GitHub with the callback <BASE_URL>/api/v1/auth/github/callback. |
Empty | Under AUTH_GITHUB=on. |
Empty under on: the CMS refuses to start. An id that is not your app's: Continue with GitHub ends on an error page at GitHub instead of coming back. |
AUTH_GITHUB_CLIENT_SECRET |
That OAuth App's client secret. A real one belongs in your secret store, not in a file. | Empty | Under AUTH_GITHUB=on. |
Empty under on: the CMS refuses to start. Wrong: GitHub refuses the code exchange, and the person lands back on the sign-in page with "GitHub sign-in did not complete. Try again in a moment." |
AUTH_GITHUB_ALLOWED_ORGS |
Comma-separated GitHub organisation logins whose members may sign in, compared without regard to case. Membership is checked on every sign-in with the person's own token. | Empty | Under AUTH_GITHUB=on. |
Empty under on: the CMS refuses to start. A misspelt login, or an organisation that restricts third-party apps and has not approved yours: every member is refused with "Your GitHub account is not in an organisation that is allowed to sign in here." |
AUTH_GOOGLE |
Sign in with Google, on or off, with the same rules as AUTH_GITHUB. |
off |
When people should sign in with Google. | on without a client id, a secret or a domain: the CMS refuses to start, naming each missing one. Any spelling but on or off: the CMS refuses to start. |
AUTH_GOOGLE_CLIENT_ID |
The id of the OAuth client you created in Google Cloud Console with the redirect URI <BASE_URL>/api/v1/auth/google/callback. Google accepts http:// there only for localhost. |
Empty | Under AUTH_GOOGLE=on. |
Empty under on: the CMS refuses to start. Wrong, or the redirect URI not registered: Google shows its own error page and never sends the person back. |
AUTH_GOOGLE_CLIENT_SECRET |
That OAuth client's secret, kept in your secret store. | Empty | Under AUTH_GOOGLE=on. |
Empty under on: the CMS refuses to start. Wrong: the exchange fails and the person lands back on the sign-in page with "Google sign-in did not complete. Try again in a moment." |
AUTH_GOOGLE_ALLOWED_DOMAINS |
Comma-separated Google Workspace domains whose accounts may sign in, matched against the account's hosted-domain claim rather than its address. * admits any Google account with a verified address, personal ones included. |
Empty | Under AUTH_GOOGLE=on. |
Empty under on: the CMS refuses to start. A domain that is not your Workspace's: everyone is refused with "Your Google account is not in a domain that is allowed to sign in here", and personal accounts always are. * on a public instance: anyone with a Google account can create an account, whatever AUTH_REGISTRATION says. |
Delivery limits
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
DELIVERY_RATE_LIMIT |
Delivery reads allowed per key per window. Counted per key rather than per address, because every reader of a browser-side site shares one key. | 600 |
When a site's traffic, or a build that reads every page, needs more than ten reads a second on one key. | Too low: a busy site or a large static build gets 429 and pages fail to load or build. Too high: one leaked key can read as fast as it likes. Not a positive number: the CMS refuses to start. |
DELIVERY_RATE_LIMIT_WINDOW_MS |
The length of that window. | 60000 |
When you change the window rather than the count. | Too long: a site that hit the limit stays refused for that long. Too short: bursts pass the limit freely. Not a positive number: the CMS refuses to start. |
DELIVERY_PREAUTH_RATE_LIMIT |
Delivery requests one address may make per window before it is turned away, counted before the key is checked so a stream of invalid keys cannot keep buying expensive hash checks. Only as accurate as TRUST_PROXY. |
1200 |
When one server legitimately sends more than this, such as a build machine holding several keys. | Too low, or TRUST_PROXY left at 0 behind a proxy so every caller shares the proxy's address: legitimate readers are refused 429 together. Too high: invalid credentials cost the server CPU for longer. Not a positive number: the CMS refuses to start. |
DELIVERY_PREAUTH_RATE_LIMIT_WINDOW_MS |
The length of that window. | 60000 |
When you change the window rather than the count. | Too long: an address that tripped the limit stays refused for that long. Too short: the guard against key guessing barely holds. Not a positive number: the CMS refuses to start. |
KEY_VERIFY_CONCURRENCY |
How many API key verifications may be hashing at once in one process; the rest queue. The hash is expensive on purpose, and this keeps a burst from filling the pool that file reads and image transforms also use. | 4 |
Rarely; only with more CPU per process and a measured queue. | Too high: a burst of key checks, even invalid ones, leaves file reads and image transforms waiting behind it. Too low: requests with a key wait in line under load. Not a positive number: the CMS refuses to start. |
Body and import limits
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
JSON_BODY_LIMIT |
The largest request body the API reads, in bytes spellings with a two-letter unit: 10mb, 512kb, or a plain number of bytes. One CLI push carries up to 500 documents, so this is measured in megabytes. Uploads are not counted here. Unlike every other size, it is not checked at boot beyond being non-empty. |
10mb (two-letter unit) |
When a push of large documents comes back 413. |
A one-letter unit such as nginx's 10m is read as 10 bytes: the CMS starts, and then every save, push and sign-in larger than that is refused 413. A value that is not a size at all, such as ten, stops the CMS during startup with option limit "ten" is invalid, outside the usual one-message list. Too low: a push comes back 413 with payload_too_large, and the client has to split the batch. Too high: one request can make the process hold that much in memory. Any proxy in front with a smaller limit cuts the request before the CMS sees it. |
IMPORT_MAX_BYTES |
The same ceiling for POST …/import alone, measured after the CLI's gzip is decoded. An archive's metadata is every version of every document, which is far more than a push. |
256mb |
When restoring a project whose archive metadata is larger than that. | Too low: restoring a real project fails with 413 before any file is uploaded. Not a size: the CMS refuses to start ("use a byte size such as 25mb"). A proxy in front needs the larger limit on that path, or the restore is cut at the edge. |
Assets
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
ASSET_BACKEND |
Where uploaded files' bytes live: disk, a mounted directory, or s3, any S3-compatible bucket. The store is checked once at boot, before the port opens. |
disk |
Set s3 for a bucket, or when more than one replica runs without a shared volume. |
Any other word: the CMS refuses to start. A store that fails its boot check: the CMS refuses to start and says why. A store that goes away later: the asset routes answer 503 with Retry-After and everything else keeps working. |
ASSET_DISK_ROOT |
The directory disk writes to. A relative path is resolved against the API's own directory. |
data/assets |
Always with disk in a container: set it to where your volume is mounted, such as /data/assets. |
Left at the default in the image: the CMS writes files into the container's own filesystem, and every file is lost when the container is replaced; under a read-only root filesystem it refuses to start instead. Not writable: the boot check fails and the CMS refuses to start. Two replicas on two separate volumes: each answers 404 for the other's files. |
ASSET_S3_ENDPOINT |
The bucket's endpoint, such as https://fra1.digitaloceanspaces.com. |
None | Under ASSET_BACKEND=s3. |
Missing under s3: the CMS refuses to start. Not a URL: the CMS refuses to start, even under disk. The wrong host: the boot check cannot reach the bucket and the CMS refuses to start. |
ASSET_S3_REGION |
The region. DigitalOcean Spaces and MinIO ignore it; the S3 client requires one. | us-east-1 |
Under s3, when your store checks the region. |
A store that checks it refuses the requests: the boot check fails and the CMS refuses to start. Empty: the CMS refuses to start. |
ASSET_S3_BUCKET |
The bucket files are written to. Keep it private: the CMS is its only reader. | None | Under s3. |
Missing under s3: the CMS refuses to start. A bucket that does not exist or that these credentials cannot see: the boot check fails and the CMS refuses to start. A public bucket: anyone who can list it reads every file, private ones included, without a signed link. |
ASSET_S3_ACCESS_KEY_ID |
The access key for the bucket. A real one belongs in your secret store. | None | Under s3. |
Missing under s3: the CMS refuses to start. Rejected by the store: the boot check fails and the CMS refuses to start. A key that can see the bucket but not write to it: the CMS starts and every upload fails. |
ASSET_S3_SECRET_ACCESS_KEY |
The secret key that goes with it, kept in the same secret store. | None | Under s3. |
Missing under s3: the CMS refuses to start. Wrong: the store rejects the signature, the boot check fails, and the CMS refuses to start. |
ASSET_S3_FORCE_PATH_STYLE |
Path-style addressing (endpoint/bucket/key) instead of virtual-hosted (bucket.endpoint/key): true for MinIO and most self-hosted stores, false for DigitalOcean Spaces. |
false |
Under s3, set to what your store expects. |
The wrong style for your store: the bucket's address is wrong, the boot check fails, and the CMS refuses to start. 1 or yes: the CMS refuses to start. |
ASSET_S3_PREFIX |
A key prefix inside the bucket, for a bucket shared with something else. Letters, digits, ., _, - and /, no leading slash, no empty, . or .. segment. |
Empty | Under s3, when the bucket holds other things. |
A leading slash or a .. segment: the CMS refuses to start. Changed after files were uploaded: the CMS looks for them under the new prefix and every existing file answers 404. |
ASSET_PUBLIC_URL |
A CDN origin in front of /d/v1/*/assets/*. When set, every asset URL the CMS hands out is built on it instead of on BASE_URL. The CDN's origin is the CMS, never the bucket. No trailing slash. |
Empty, which means BASE_URL |
When a CDN serves your images and files. | A trailing slash: the CMS refuses to start. A development value such as http://localhost:3000 left in production: every image and file URL in delivered content points at localhost and breaks on every reader's machine. A CDN in front of the bucket instead of the CMS: variants, SVG safety headers and private files stop working. |
ASSET_SIGNING_SECRET |
Signs the links that open a private file. Blank, the key is derived from SESSION_SECRET. Setting it lets you rotate file links without signing anyone out. |
Unset, derived from SESSION_SECRET |
When you want to rotate private links on their own, or never tie them to the session secret. | Set but shorter than 32 characters: the CMS refuses to start. Changed: every outstanding private-file link stops working at once. A value copied from an example: anyone who has read the example can sign links to your private files. |
ASSET_MAX_BYTES |
The largest single upload, in bytes spellings. |
25mb |
When people upload larger video or PDF files. | An upload over it is refused 413. Larger than what your proxy or ingress accepts: the upload is cut at the edge and the person sees the proxy's error, not the CMS's. Not a size: the CMS refuses to start. |
ASSET_PROJECT_QUOTA_BYTES |
How much one project may hold, in the same spellings. 0 is unlimited. |
5gb |
When a project needs more room, or you want to cap it lower. | Too low: uploads are refused 409 with "delete something first" while the project still has work to add. 0 on a shared instance: one project can fill the disk or the bucket for everyone. Not a size: the CMS refuses to start. |
ASSET_MIME_ALLOWLIST |
The media types an upload may be recognised as, comma-separated. The type is read from the file's bytes; what the browser declared and the file's extension are ignored. | JPEG, PNG, GIF, WebP, AVIF, SVG, PDF, MP4, WebM, MP3 and WOFF2 (image/jpeg,image/png,image/gif,image/webp,image/avif,image/svg+xml,application/pdf,video/mp4,video/webm,audio/mpeg,font/woff2) |
When a project needs a type outside that list. | A type left off: those uploads are refused with "Files of type … are not allowed." image/heic added: HEIC uploads are still refused with "This image could not be read", because the image library cannot decode it. A malformed entry or an empty list: the CMS refuses to start. |
ASSET_IMAGE_WIDTHS |
The w= widths a scaled image may be asked for, comma-separated. Anything else is refused, which keeps a caller from filling the cache with one variant per pixel. |
320,640,960,1280,1920,2560 |
When your site's layout needs other widths. | A width removed while sites still ask for it: those srcset entries answer 422 and the images break. Anything that is not a list of positive whole numbers: the CMS refuses to start. |
ASSET_UPLOAD_RATE_LIMIT |
Uploads allowed per signed-in person or per key per window. | 60 |
When people or scripts upload larger batches at once. | Too low: a large batch dropped into Media stops part-way with 429 and a Retry-After. Too high: one key can fill the quota in a burst. Not a positive number: the CMS refuses to start. |
ASSET_UPLOAD_RATE_LIMIT_WINDOW_MS |
The length of that window. | 60000 |
When you change the window rather than the count. | Too long: someone who hit the limit waits that long to finish a batch. Too short: the limit barely slows a runaway script. Not a positive number: the CMS refuses to start. |
ASSET_TRANSFORM_CONCURRENCY |
How many images one process scales and re-encodes at once. A memory ceiling: one transform of a 50-megapixel image needs about 200 MB, and past this number a variant request gets 503 with Retry-After. |
4 |
Only together with the container's memory limit. | Raised without more memory: a burst of new variants makes the process run out of memory and the container is killed mid-request. Too low: under load, readers get 503 for images that are not cached yet. Not a positive number: the CMS refuses to start. |
Webhooks
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
WEBHOOK_SECRET_KEY |
Encrypts every webhook's signing secret at rest. Exactly 32 bytes in standard base64; generate it with openssl rand -base64 32. |
None | Always: the CMS refuses to start without it. | Missing, not base64, or not 32 bytes: the CMS refuses to start. Lost or changed: the CMS can no longer sign deliveries for existing webhooks, and each one has to have its secret rotated. The example key of 32 zero bytes (AAAA…A=) boots, and anyone can read it. |
WEBHOOK_ALLOW_HTTP |
1 accepts http:// webhook URLs, for local development; 0 allows https:// only. |
0 |
Only on a development machine whose receiver has no TLS. | 0 in development: a webhook at http://localhost is refused when you save it. 1 in production: deliveries and their signatures cross the network unencrypted. true: the CMS refuses to start. |
WEBHOOK_ALLOW_PRIVATE_NETWORKS |
1 lets a webhook reach private, loopback and link-local addresses, for a receiver inside the same cluster or machine. With 0, such a URL, or a hostname that resolves to one, is refused when it is saved and again when it is called. |
0 |
When the receiver lives on a private network next to the CMS. | 0 with an in-cluster receiver: the webhook is refused when saved. 1 on an instance where strangers can create projects: anyone with a project can make the CMS send requests to your internal services. true: the CMS refuses to start. |
WEBHOOK_TIMEOUT_MS |
How long one delivery attempt waits for the receiver's whole response. | 10000 |
When a receiver is known to answer slowly. | Too short: a slow but healthy receiver is counted as failing, retried, and paused after twenty failed attempts in a row. Too long: a dead receiver holds one of the eight delivery slots that much longer per attempt. Not a positive number: the CMS refuses to start. |
WEBHOOK_RATE_LIMIT |
Deliveries per minute per webhook; the rest wait for the next minute. | 60 |
When a busy project publishes faster than that and its receiver can keep up. | Too low: deliveries back up, and sites hear about a publish minutes late. Too high: a burst of publishes can overwhelm a small receiver. Not a positive number: the CMS refuses to start. |
WEBHOOK_WORKER |
1 lets this process run the delivery loop. Every replica runs it, and a Redis lease picks one to send; 0 is for a process that should never send. |
1 |
Set 0 only on processes where a separate worker sends deliveries. |
0 on every process: no webhook is ever delivered, and deliveries wait in the queue. true: the CMS refuses to start. |
Promotion
| Variable | Meaning | Default | Needed when | If wrong |
|---|---|---|---|---|
PROMOTION_RATE_LIMIT |
Compares, promotions and clones one person or key may ask for per window: one budget across the three. A hundred lets the dashboard read a 20,000-document report and promote it in one sitting. | 100 |
When environments are larger than 20,000 documents, or a script promotes often. | Too low: a large compare or promotion stops part-way with 429, and Try again starts the whole report again. Too high: a script can turn promotion into a write loop against the database. Not a positive number: the CMS refuses to start. |
PROMOTION_RATE_LIMIT_WINDOW_MS |
The window that budget is spent in. | 60000 |
When you change the window rather than the count. | Too long: someone who used the budget waits that long before the next promotion. Too short: the limit no longer stops a runaway script. Not a positive number: the CMS refuses to start. |
Related
- Run with Docker sets the five required variables in a compose file.
- Sign-in with GitHub or Google walks through the registration and sign-in group.
- File storage explains the asset group: disk or a bucket, a CDN, quotas and types.
- Behind a proxy and TLS covers
TRUST_PROXY,BASE_URLand the body limits at the edge. - Kubernetes with Helm maps these variables onto the chart's values.
- Limits and quotas puts every rate and size limit in one table.