Skip to content

Install and set up GuestCraft

GuestCraft is pre-alpha. The application supports the front desk, rates and policies, folios, night audit, reports, team and roles, Channex booking import, guarded ARI publication and guest messages, API keys and webhooks, and a mobile app; several of these are partial. See the product status and ARI activation/recovery requirements before enabling channel writes.

Use the Neon, Supabase and EU Deploy Buttons and the complete one-click guide. The repository must be public for Vercel to clone it.

Compose runs PostgreSQL 17, a one-shot migration service, the web app and the worker. It can run a published release from GitHub Container Registry, or build your checkout.

In the repository root, copy .env.example to .env. Generate four independent values with openssl rand -hex 32. Set AUTH_SECRET, GUESTCRAFT_DATABASE_ROLE_SECRET, POSTGRES_PASSWORD, and ENCRYPTION_KEYS in that file; prefix the encryption value with 1:. Keep .env private.

Then choose one of the two blocks of .env.example:

  • Local trial (the default): GUESTCRAFT_ENV=development and GUESTCRAFT_BASE_URL=http://localhost:3000. Only this machine reaches the instance: Compose publishes the web port on 127.0.0.1.
  • Public deployment, behind an HTTPS reverse proxy: comment out the two local lines and uncomment GUESTCRAFT_ENV=production, your public GUESTCRAFT_BASE_URL, GUESTCRAFT_CLIENT_IP_HEADERS and GUESTCRAFT_TRUSTED_PROXIES (see the client address). Do this before the proxy exposes the instance. The web refuses to start, with a message naming both settings, when the base URL uses https: or a host other than localhost, 127.0.0.1 or [::1] without them, even with GUESTCRAFT_ENV=development.

Each GitHub release publishes three multi-platform images (linux/amd64, linux/arm64):

Image Runs
ghcr.io/spinny03/guestcraft the web app and HTTP API
ghcr.io/spinny03/guestcraft-worker the resident worker
ghcr.io/spinny03/guestcraft-migrate the one-shot migration command

Tags are the exact version (0.1.0) and its minor line (0.1). There is no latest tag before 1.0, because a minor version may change behaviour. Use the docker-compose.yml and .env.example of the release you install, from its tag:

Terminal window
git clone --branch v0.1.0 --depth 1 https://github.com/Spinny03/guestcraft.git
cd guestcraft
cp .env.example .env # then set the four secrets as described above
docker compose up

The image version in docker-compose.yml is the version of that release. To run another published version with the same file, set GUESTCRAFT_VERSION in .env. Before deploying, verify the images with the commands in the release guide. Images exist from the first release onward; until then, build the checkout.

To upgrade, back up the database, read the release notes (they list each migration’s expand or contract kind), switch to the new tag or version and run docker compose up again. The migration service applies the new migrations before the web app and worker start. Migrations are forward-only; rollback is possible across expand-only SQL when the previous runtime already supports the compatibility window. For that rollback, use GUESTCRAFT_VERSION=<previous> docker compose up -d --no-deps web worker; plain docker compose up stops at the older migrate service by design and also stops the running web and worker, leaving them in Created state. The --no-deps command is the recovery. A contract migration needs roll-forward or a separately evaluated restore.

While any workspace is in exclusive Channex account mode, do not roll back below the release that added the separate Channex retired-target fence, even though its migration is expand; see booking import rollback.

Terminal window
docker compose -f docker-compose.yml -f docker-compose.build.yml up --build

docker-compose.build.yml builds the same web, worker and migrate Dockerfile targets that a release publishes, under local tags that never replace a pulled release image. GUESTCRAFT_LOCAL_TAG (default local) names those tags; give each checkout its own when several build on one Docker daemon. Set COMPOSE_FILE=docker-compose.yml:docker-compose.build.yml in .env to make it the default.

Compose starts PostgreSQL 17, runs migrations once, then starts the web app at http://localhost:3000. The web port binds to loopback by default, and PostgreSQL data persists in a named volume.

The migration service prints the setup URL and private code while the instance is unconfigured. Open /setup, enter the code, and provide the operator’s name, email, password, company name and workspace name. Passwords must be 12–128 characters. Setup creates the operator, company and production workspace in one transaction and cannot run again after completion.

Sign in at /sign-in. On /onboarding, select the workspace and supply your company’s production Channex API key (the setup workspace is a production one; a sandbox workspace takes a Channex staging key). You may complete this step later. A successful connection validates property access, encrypts the credential, and displays the accessible Channex properties. It does not import, map or synchronize them. Public sign-up remains closed.

.env.example is the configuration template; validation lives in server/config.

  • GUESTCRAFT_ENV: development or production. If omitted, VERCEL_ENV determines the runtime environment; a hosted preview runs the same application as production.
  • GUESTCRAFT_INSTANCE_ID: stable instance identifier, 4–16 lowercase letters or digits. Keep it consistent between migrations and the web runtime.
  • GUESTCRAFT_BASE_URL: canonical public origin, such as http://localhost:3000 or https://pms.example.com, without a path. Production requires HTTPS except on localhost. Every browser address used for sign-in or writes must be this origin or be listed in GUESTCRAFT_TRUSTED_ORIGINS.
  • GUESTCRAFT_TRUSTED_ORIGINS: optional comma-separated exact origins for other addresses of the same instance, such as https://guestcraft-example.vercel.app,https://second.example.com. Include the scheme and port, with no path, query or wildcard. Passkeys work only on the host of GUESTCRAFT_BASE_URL and its subdomains, over HTTPS (or plain HTTP on localhost), never on an IP address: for example https://desk.pms.example.com or https://pms.example.com:8443. Any other listed origin, such as a second domain, http://192.168.1.20:3000 or a plain-HTTP host, stays trusted for sign-in but offers no passkey: startup logs one warning naming each such origin with its fix, the organization security screen lists them under “Passkeys unavailable on”, and a passkey ceremony from one answers PASSKEY_ORIGIN_UNAVAILABLE. To offer passkeys there, redirect that address to the base URL or serve it under the base domain. An unlisted address returns request.untrusted_origin (403) on writes, including sign-in; the sign-in page explains the configuration error, and the server logs the refused origin without its query or credentials.
  • ENCRYPTION_KEYS: independent integration keys, newest first as 2:secret,1:secret; each secret has at least 32 characters. Required to save Channex connections.
  • AUTH_SECRET: at least 32 characters; used for sessions and the setup code.
  • GUESTCRAFT_DATABASE_ROLE_SECRET: recommended independent secret (32+ characters) for database roles; omitted legacy installations use their original AUTH_SECRET.
  • GUESTCRAFT_DATABASE_ROLE_ID: optional 4–16 lowercase alphanumeric role namespace, defaulting to the instance ID. It does not change instance ownership.
  • DATABASE_URL: pooled PostgreSQL URL for the HTTP runtime. POSTGRES_URL is an accepted fallback. The app connects as its dedicated runtime role.
  • DATABASE_URL_UNPOOLED: PostgreSQL owner URL for migrations, with permission to create roles. It must keep one PostgreSQL session: the direct URL (on Neon, the unpooled one) or a session pooler (on Supabase, port 5432), never a transaction pooler. POSTGRES_URL_NON_POOLING is an accepted fallback.

Optional settings include GUESTCRAFT_DATABASE_POOL_SIZE (default 10, range 2–100), GUESTCRAFT_LOG_LEVEL, and the BETTER_AUTH_SECRETS rotation ring.

The mobile app reads two more (discovery and device sessions): GUESTCRAFT_INSTANCE_NAME, the name it shows before sign-in (1–100 characters; the host of GUESTCRAFT_BASE_URL by default), and GUESTCRAFT_MOBILE_APP_SCHEMES, the comma-separated URI schemes of the apps that may sign in (default dev.guestcraft.app, the official app; add a branded app’s scheme, or exp for Expo Go during development). http and https are refused.

OAuth applications and MCP clients (OAuth applications) register themselves without signing in by default, within per-address and per-instance limits. GUESTCRAFT_OAUTH_OPEN_REGISTRATION=false lets only the owners and administrators of an organization register one, signed in, with the browser request that OAuth applications shows; no screen sends it yet. The protected resources and the issuer follow GUESTCRAFT_BASE_URL, which must be the address clients use.

Stored files, such as fault photos, are kept in the database unless GUESTCRAFT_FILE_STORAGE names an object storage service; its settings are listed there.

Outbound webhooks (API keys and webhooks) reach public HTTPS addresses only. GUESTCRAFT_WEBHOOKS_ALLOW_PRIVATE=true lets them reach private and local addresses and plain http: in development; with GUESTCRAFT_ENV=production the instance refuses to start with it. Webhook signing secrets are sealed with ENCRYPTION_KEYS.

Sign-in limits count requests per client address, which a reverse proxy passes in a header. GUESTCRAFT_CLIENT_IP_HEADERS names that header and GUESTCRAFT_TRUSTED_PROXIES lists the proxy’s ingress IP addresses or CIDRs; they are set together. Outside Vercel they are required whenever other machines reach the instance: GUESTCRAFT_ENV=production, or a GUESTCRAFT_BASE_URL that uses https: or another host than the loopback, whatever GUESTCRAFT_ENV says. Only a local trial on http://localhost may omit them.

  • Set the header to one your proxy overwrites on every request, never one it passes on from the client. For example, use x-forwarded-for and 192.0.2.10 for a proxy at that address; never trust all private networks by default. Better Auth walks forwarded chains from the right and ignores only the configured trusted hops.
  • Make the proxy the only way in: keep the app’s port bound to the loopback or firewalled. Better Auth cannot compare the connection’s peer with the trusted list, so a client that reaches the app directly could send any forwarded address.
  • Vercel needs neither setting: it defaults to its platform-set x-vercel-forwarded-for header.

HSTS (Strict-Transport-Security) is sent when GUESTCRAFT_BASE_URL is https: (or on Vercel), not from a request’s X-Forwarded-Proto, which any client can set. Terminate TLS at the proxy.

Every sign-in passes two limits, both shared by all runtimes through the database:

  1. Per client: 3 sign-in requests per 10 seconds per client address. A request over it answers 429 and goes no further: it counts no attempt against any account.
  2. Per account: five attempts in a fixed 15-minute window, independent of the address, counted before the password is checked and cleared by a successful sign-in. A sixth attempt answers 429 with Retry-After set to the seconds left, the same for every address whether or not an account exists.

Because the per-client limit comes first, one client address adds at most three lockout rows per 10 seconds and can have at most 270 live lockout rows in a 15-minute window, assuming trusted client attribution. Expired rows can remain physically while idle; later admitted requests remove them in batches of at most 100. The threat model states the bound.

When the fifth failure locks an account, the audit log records it as team.sign_in_locked in every workspace whose team lists the person, by the system rather than a user, with the time and the end of the lock (locked_until). The entry names the user only; it holds neither the e-mail address nor the client’s address. Managers see it in Activity log, which shows who is being locked out on purpose. An address without an account locks too, and records nothing.

The lock ends on its own when the window ends. A team manager can lift it at once in Users and roles: the member’s status reads “Sign-in locked until…” with an Unlock sign-in action (DELETE /api/v1/members/{member_id}/sign_in_lock, audited as team.sign_in_unlocked). An organization owner’s or administrator’s lock is lifted only by an organization owner or administrator. A manager may lift their own lock from a session opened before it, for example on another device: whoever holds that session is the legitimate user, and the unlock is audited like any other.

If every administrator is locked out, nobody needs to edit the database: each lock ends by itself at most 15 minutes after the first of its failed attempts, and the next correct password signs in. Someone who knows a staff member’s address can keep that account locked 15 minutes at a time; there are no e-mail alerts or progressive delays.

Two operating requirements keep these limits sound:

  • The edge. The proxy in front of the web service must overwrite the client-address header on every request and be the only way in, as the client address explains. Otherwise a client chooses its own address for the per-client limit.
  • The clocks. The application’s clock decides when a window ends, and every runtime writes to the same table. Keep the clocks of all web instances synchronized (NTP): an instance far ahead would end other instances’ locks early.

Pass settings to the process that needs them. Compose’s .env supplies substitutions for docker-compose.yml, including GUESTCRAFT_VERSION (the published version to run) and COMPOSE_FILE; additional runtime settings, including trusted origins, must also be forwarded in the web service’s environment block. If Docker is served through an HTTPS reverse proxy, set GUESTCRAFT_BASE_URL to the address operators open, and list any other domains in GUESTCRAFT_TRUSTED_ORIGINS (passkeys work only on the base host and its subdomains). Locally, open http://localhost:3000: passkeys work there, not on http://127.0.0.1:3000. Channex keys are entered per workspace in onboarding, rather than configured as deployment variables. Local and preview deployments can contact production Channex.

Use the Node.js version in .node-version and pnpm 12.15 through Corepack:

Terminal window
corepack enable
pnpm install
pnpm --filter @guestcraft/server migrate
pnpm --filter @guestcraft/web dev

Provide a running PostgreSQL 17 database and the configuration above before running these commands. The migration command loads a repository-root .env when present. For the web dev command, supply settings in the process environment or apps/web/.env.local; Next.js does not automatically load the repository-root .env from apps/web.

Migrate with the direct or session-pooler owner URL, then serve requests through the pooled URL. Use the private code from migration output to complete setup. No Channex key or remote account is needed for the test suites; see CONTRIBUTING for checks and integration database options.

The Next.js app supports Vercel with project root apps/web and Node.js 24. The one-click guide uses a Marketplace database and runs migrations during production builds, before the new version serves traffic. Migrations do not run during HTTP requests. GUESTCRAFT_INSTANCE_ID and GUESTCRAFT_BASE_URL derive from stable Vercel system variables unless explicitly set. The derived base URL is the production domain: add the .vercel.app alias and any second custom domain to Production-scoped GUESTCRAFT_TRUSTED_ORIGINS (sign-in works there, passkeys only on the production domain), or redirect them to it to offer passkeys everywhere. For a preview, use Preview-scoped GUESTCRAFT_BASE_URL for its exact URL or list that URL in Preview-scoped GUESTCRAFT_TRUSTED_ORIGINS; repeat when a deployment URL changes. For manual hosting, provide a runtime URL and a direct or session-pooler owner URL and run migrations before serving the new version.

For a self-hosted deployment, place the web service behind an HTTPS ingress. Preserve the database volume, instance identifier and authentication secrets across restarts. A preview URL does not isolate a database or disable production Channex access; use a separate database and instance configuration when you need an independent installation.

Photos of maintenance faults are stored files (ADR 0046): the database keeps them by default, and an instance may keep them in object storage instead. The web and mobile apps downscale them to a JPEG of at most 1,600 pixels, usually 150–400 kB, and the server refuses files above 2,000,000 bytes and more than 10 per ticket. Free database plans hold about 500 MB in total (Neon’s free plan 0.5 GB, Supabase’s free plan 500 MB): about 1,500 photos would take half of it. Watch the database size in your provider’s dashboard, remove photos that are no longer needed from the ticket’s detail, move the files to object storage, or choose a larger database plan. Automatic photo retention is planned.

Object storage is optional: nothing paid is ever required, and without it every file stays in the database. With it, a new file’s bytes go to a private bucket or store, written before the file’s database row; its row records where the bytes are and holds no content. Reads follow each file’s row, so an instance whose files are partly in the database and partly in object storage serves all of them; a read refuses an object whose size differs from its row’s. Downloads still pass through the API and its permission checks: no object is ever public, and no link to the store leaves the server.

A bucket or store belongs to one instance. Every object’s name starts with the instance’s identifier (GUESTCRAFT_INSTANCE_ID), then the workspace’s identifier and the file’s key: <instance>/<workspace>/<key>. The instance lists and deletes only names under its own prefix, so a second instance that shares the bucket by mistake, such as a staging deployment on the production bucket, never deletes the first one’s files; still, give each instance its own bucket or Blob store, and keep the instance identifier unchanged once files are stored.

Each request to object storage ends after 10 seconds: the file store calls it while its database transaction is open.

GUESTCRAFT_FILE_STORAGE chooses the store: database (the default), s3 for any S3-compatible service, or vercel_blob for a private Vercel Blob store. Set the same values on the web, the worker and the migration command. GUESTCRAFT_FILE_WRITES is object by default; set it to database to write new files to the database again while the existing ones are moved back.

An S3-compatible service takes these settings; every one except the last is required. Use an access key restricted to the one bucket, allowed to read, write, list and delete objects.

Setting Meaning
GUESTCRAFT_S3_ENDPOINT The service’s http or https URL, with the path of its S3 API when it has one
GUESTCRAFT_S3_REGION The region the requests are signed for
GUESTCRAFT_S3_BUCKET The bucket, created beforehand and private
GUESTCRAFT_S3_ACCESS_KEY_ID The access key’s identifier
GUESTCRAFT_S3_SECRET_ACCESS_KEY The access key’s secret
GUESTCRAFT_S3_FORCE_PATH_STYLE true to put the bucket in the path (endpoint/bucket/key) rather than in the host name (bucket.endpoint/key); false by default
Provider Endpoint Region Path style
AWS S3 https://s3.<region>.amazonaws.com the bucket’s, such as eu-west-1 false
Cloudflare R2 https://<account-id>.r2.cloudflarestorage.com (the bucket’s EU jurisdiction: https://<account-id>.eu.r2.cloudflarestorage.com) auto true or false
Supabase Storage https://<project-ref>.storage.supabase.co/storage/v1/s3, the S3 endpoint in the project’s Storage settings, with an S3 access key created there the project’s region, such as eu-central-1 true
MinIO its address, such as http://minio:9000 us-east-1, unless the server sets another true

Plain http endpoints suit a private network only, as Compose’s MinIO below; a public service needs https.

A private Vercel Blob store suits a Vercel deployment. Create a Blob store with private access in the Vercel dashboard and connect it to the project: Vercel then sets BLOB_READ_WRITE_TOKEN, which GuestCraft uses. Alternatively, set BLOB_STORE_ID without a token, and the Vercel Blob SDK signs in with the deployment’s Vercel OIDC token. Every object is written with private access under its own name, and read only with the store’s credentials. The adapter is tested against the SDK’s interface, not against a real store: continuous integration has no Vercel account, so verify uploads and downloads on your deployment after enabling it.

Compose has one optional service, MinIO, an S3-compatible server, in the object-storage profile. Uncomment the object storage block of .env.example in .env and set GUESTCRAFT_S3_SECRET_ACCESS_KEY to a new random value (openssl rand -hex 32). The block also sets COMPOSE_PROFILES=object-storage, so docker compose up starts MinIO and a one-shot minio-bucket service that creates the bucket; without it, use docker compose --profile object-storage up. MinIO publishes no port: the web, the worker and the migration command reach it as http://minio:9000 on Compose’s network, and its data persists in the minio volume. Back it up together with the database volume. MinIO refuses to start without both credentials, rather than start with its well-known default ones.

The image is a community build of MinIO’s sources (pgsty/minio), because the MinIO project no longer publishes minio/minio images. Any other S3-compatible server works with the same settings.

The object is written before its row, and removing a file deletes only its row, because an object cannot join the database transaction: a transaction that fails after writing its object, and every removal, leave an object that no row names. The worker collects those orphans. Every ten minutes, and when a hosted drain starts, it runs the collection, which does nothing within an hour of its last run, so object storage sees at most one listing an hour; free plans count listings (Vercel Blob counts them as advanced operations). Each run lists one page of at most 1,000 of the instance’s objects, continuing from where the last one stopped and starting again at the first object after the last page, and deletes the objects older than 24 hours whose file no longer has a row in object storage, 50 at a time. It keeps the objects of an archived workspace, so that restoring the workspace restores its files, deletes those of a workspace that no longer exists, and never deletes an object outside the instance’s prefix or whose name is not the instance, a workspace identifier and a key. If the database owner that ran the migrations cannot bypass row-level security, it deletes nothing, rather than take every file for an orphan. It logs only counts. It runs only on the instance that owns background writes: never on a preview deployment, nor over a copy of the database restored elsewhere.

A removed file’s bytes therefore remain in object storage for at least a day, and until the collection reaches them. It reads one page an hour, so a full pass over a store with N of the instance’s objects takes about N / 1,000 hours (four days for 100,000 photos): count that delay when you compare the bucket’s size with the files the instance shows.

Move files between the database and object storage

Section titled “Move files between the database and object storage”

With object storage configured, and with the owner’s direct URL as for migrations, the move command moves every stored file’s bytes, in transactions of 20 files:

Terminal window
pnpm --filter @guestcraft/server files:move -- --to object
pnpm --filter @guestcraft/server files:move -- --to database

With Docker Compose, run it from the migration image: docker compose run --rm migrate node src/migrate/files-move-main.ts --to object.

  • To object storage: first set GUESTCRAFT_FILE_STORAGE and its settings on the web and the worker, so that new files go there, then run --to object. Each file’s object is written, then its row loses its content. Back up the database first; the database’s size shrinks only after PostgreSQL reuses or vacuums the freed space.
  • Back to the database: set GUESTCRAFT_FILE_WRITES=database on the web and the worker, keeping the object storage settings, then run --to database. Each file’s bytes are copied into its row, and its object is deleted after the batch commits. Once the command reports no more files, remove the object storage settings.

The command checks that the database belongs to the configured instance and prints how many files it moved. A batch that fails changes nothing, and running the command again continues with the files still to move; an object left by a run that stopped between a commit and the deletions is deleted by the orphan collection. A file recorded in object storage whose object is missing stops the move back with an explanation and the query that lists the files among which it is: select workspace_id, key from guestcraft.stored_files where location = 'object' order by workspace_id, key limit 20. Restore its object, or remove the photo from its ticket, then run the command again. Any other failure prints a generic message, without values: check the database and the object storage settings, then run the command again.

Session rotation uses BETTER_AUTH_SECRETS (version:secret, newest first); its first value must match AUTH_SECRET. Before rotating a legacy installation without an independent database secret, set GUESTCRAFT_DATABASE_ROLE_SECRET to its original AUTH_SECRET in build, web and worker environments and deploy that configuration first. Then rotate AUTH_SECRET and the session ring while keeping the database secret unchanged. New installations should generate an independent database secret from the start.

Database credential rotation is a staged cutover:

  1. Keep the serving deployment’s role ID and secret. Configure the candidate build and runtime with a new GUESTCRAFT_DATABASE_ROLE_ID (4–16 lowercase letters/digits) and an independently generated GUESTCRAFT_DATABASE_ROLE_SECRET. Keep GUESTCRAFT_INSTANCE_ID unchanged.
  2. Run the candidate migration command. One session advisory lock covers journal verification, existing-role login validation, migrations, role creation, new-role login validation, grants and job registration. SQL migrations must succeed before roles are created or corrected. Existing role passwords are never replaced; a changed secret with the same role ID fails with database.credentials_mismatch before any SQL is applied. An older migration command still refuses a newer journal before touching roles, grants or queues; a compatible older runtime may serve through expand-only entries.
  3. Verify the candidate can open fresh app and worker connections, then promote it. The old pair keeps its passwords and privileges. A runtime with the expand/contract check can cold-start after a failed build, unpromoted candidate or Vercel Instant Rollback when every extra committed migration is expand and its SQL and deployment grants preserve old behavior. A newer contract entry ends that window. Vercel Instant Rollback does not restore the database.
  4. After the candidate is verified and serving, stop/drain every old web and worker process. As the owner, revoke the old roles’ database/schema/table/sequence/routine grants and drop only that retired pair. Check all databases before dropping cluster-wide roles. Do not use DROP OWNED or REASSIGN OWNED against a shared owner. Until cleanup, both pairs retain runtime privileges.

After a failed candidate, inspect the journal before rolling back: select version, name, kind from guestcraft_meta.migrations order by version desc limit 5;. The compatible runtime logs compatible newer migrations tolerated with the versions and names it accepts. A partially failed batch retains earlier committed migrations. If all extra entries are expand, the previous compatible web and worker runtimes may continue, including fresh instances, while you repair the candidate; do not run the old migration command. If any extra entry is contract, fix and redeploy code matching the journal. To make a breaking change, first release additive expand SQL, then release code that uses the new schema while retaining the old objects, and only after old processes have drained release a contract cleanup. Never edit an applied migration or delete journal rows to make old code start. Restoring a backup is a separate recovery operation with potential data loss. First compatibility release: a deployment built before this runtime change retains the strict revision check and cannot cold-start after any later migration commits; stage the first compatible runtime with roll-forward readiness. The staging process is manual; GuestCraft does not promote deployments or retire roles automatically.

Rotating AUTH_SECRET signs everybody out but keeps every sign-in lock. The lockout names an account by an HMAC of its folded address under a random key that the sign-in lockout migration generated once and stored in guestcraft_auth.sign_in_lockout_key, which only the app role can read; it never derives from AUTH_SECRET. The key is not a credential: it keeps addresses out of the lockout table, which a backup restores along with it. There is nothing to rotate.

Integration encryption rotates independently with ENCRYPTION_KEYS. Add a new version first and keep the older integration keys. A key version is immutable once used: never assign different key material to that version, even after rotating away from it, because backups still need it. Maintenance records a fingerprint of every key version it read or wrote a value with, bound to that version number. The fingerprint is an HMAC of the key under its own label, kept in an owner-only table. Whoever can read that table can check a guess of the key offline with one HMAC, a few times less work than checking it against a stored ciphertext. Generate every ENCRYPTION_KEYS value randomly, with at least 32 bytes as openssl rand -hex 32 does, so that no guess is feasible. Every later run compares each configured version with the recorded one and refuses other material before changing anything, whichever version the run targets. Configure that version with the material it was first used with; to retire that material, finish with it and then rotate to a new version number. A version is recorded only once a value depends on it: if a run stopped before its first batch, corrected material for its target version is accepted. To migrate legacy auth-derived v1 credentials, retain the old auth ring or set ENCRYPTION_LEGACY_AUTH_SECRETS explicitly to those old versioned secrets. New connections always use the independent keyring; it never falls back to authentication secrets for writes.

Back up the database and securely retain the keys needed by that backup. With both pooled/direct deployment URLs and the complete keyrings configured, run:

Terminal window
pnpm --filter @guestcraft/server credentials:reencrypt

With Docker Compose, run the same command from the migration image, which ships it: docker compose run --rm migrate node src/migrate/reencrypt-main.ts. Forward DATABASE_URL and the keyrings into the migrate service’s environment first.

The command rewrites saved Channex credentials, Channex booking webhook secrets, encrypted booking revision archives, Messages records (thread titles, guest messages, outbox payloads, request details and reply templates), the current and previous signing secrets of outbound webhook endpoints, the secret keys and webhook signing secrets of payment provider connections and the OIDC client secrets of single sign-on connections in transactions of at most 500 values. It first seals any single sign-on client secret a pre-release version stored in plaintext, then lets the database refuse plaintext ones (ADR 0055). When it refuses, it prints why and what to do: a value no configured key can read, other material for a recorded version, values still written with an older key, a value the target version alone cannot read, a missing ENCRYPTION_KEYS, or a database of another instance. Any other failure prints a generic message; rerun after checking the instance, database and encryption keys. It commits each batch with a durable cursor, so a crash or an unreadable value leaves completed batches in place and a rerun resumes. It holds connection and sync row locks only for their short batches; archive scanning does not block booking intake on the sync row. Each value is decrypted and rewritten atomically, and completion performs a separate batched, read-only authenticated decrypt of every credential, Channex webhook secret, archive, Messages record, endpoint signing secret, payment connection secret and single sign-on client secret using the target key alone, including reruns after completion. A prefix alone is never accepted as verification. It checks the instance identifier, preserves connection metadata and makes no Channex or payment provider calls. It prints the values rewritten by this invocation. Stop old-key writers before the final run; keep both old and new keys configured through every retry. Then restart web and worker with the new key alone and verify reading/import. Retain older keys separately for backups: a message sealed with a removed key is shown as unreadable instead of hiding the inbox, but it cannot be recovered without that key.

If an old key is removed while archives still need it, booking intake continues: each affected quarantine shows “The encrypted copy cannot be read” in Channels and is no longer replayed automatically. Add the old key back, run the command, then retry those revisions. With no ENCRYPTION_KEYS, login/setup and legacy reads remain available but new connection writes return server.unavailable before contacting Channex.

Continue with the API guide, SDK guide, or the running instance’s documentation hub at /docs.

Docker Compose starts a resident worker after migration. Import writes require a production application environment and the initialized owning instance ID; connection validation alone does not start import. Vercel previews cannot acknowledge or register webhooks. A restored copy must use a new configured instance ID. For a host scheduler, configure GUESTCRAFT_CRON_SECRET (or Vercel’s CRON_SECRET) and call the authenticated budgeted worker route every five minutes. The Vercel configuration schedules it daily on Hobby; use a higher-frequency scheduler when booking import latency matters. See booking import for mapping, ownership, the 120-second budget and recovery.

Migrations (DATABASE_URL_UNPOOLED) need one PostgreSQL session: the direct URL (on Neon, the unpooled one) or a session pooler (on Supabase, port 5432). A transaction pooler is refused with migration.session_required before migration SQL, and the known transaction-pooler addresses (Neon’s -pooler hosts, Supabase’s port 6543) are refused by the configuration check; pooled URLs remain supported for app and worker connections. See the migration diagnostics.