Self-hosting

Deployment profiles

Docker Compose profiles (supabase, standalone with PostgreSQL, MinIO, worker, built-in auth, SSE and Mailpit), the on-premises, Azure and Google Cloud reference profiles with their smoke tests, the full instance migration test, release gates and the Bicep / Terraform templates.

Ready-made ways to run UserTasks, from the default Supabase setup to a stack with no Supabase at all, plus four reference profiles (on-premises, Azure, Google Cloud, AWS). Each reference profile has a smoke test, and all of them are release gates. The provider choices are described in Infrastructure and providers. To move an existing install, see Switching providers.

ProfileDatabaseAuthStorageRealtimeSchedulerEmailSecretsCache
supabase (default)SupabaseSupabase AuthSupabase StorageSupabase Realtimepg_cronResendenvmemory
standalonePostgreSQL 16 (+ PgBouncer)built-inMinIO (S3)SSEworker containerSMTP → MailpitenvValkey
onpremas standalone, network-isolatedbuilt-in (+ Keycloak OIDC)MinIOSSEworkerSMTPenvValkey
azurePostgreSQL (Flexible Server)built-in + Entra ID OIDCBlob StorageSSEContainer Apps Job tickCommunication ServicesKey VaultAzure Cache for Redis (Valkey locally)
gcpPostgreSQL (Cloud SQL)built-in + Google OIDCCloud StorageSSECloud Scheduler tickSMTP relaySecret ManagerMemorystore (Valkey locally)
awsPostgreSQL (RDS)built-in + Cognito OIDCS3SSEEventBridge tickSESSecrets Manager (+ KMS)ElastiCache (Valkey locally)

Docker Compose

docker-compose.yml has two profiles. The profile decides which containers start; .env decides which providers the app uses.

supabase (default)

The app only, against a Supabase project (hosted or supabase start):

bash
cp .env.example .env      # Supabase URL and keys, ENCRYPTION_KEY, CRON_SECRET, …
docker compose up -d --build
docker compose exec -e BOOTSTRAP_PASSWORD='a-strong-password' app \
  node admin.cjs bootstrap --email admin@example.com --org "Acme Inc"

This works exactly as before; see Self-hosting.

standalone

No Supabase. These containers start:

ContainerWhat it does
postgresPostgreSQL 16 (volume pgdata).
migrateRuns once on every up: usertasks-admin db migrate, then storage init, which creates the buckets.
minioS3 storage (volume miniodata). The console is at 127.0.0.1:9001. Files are served by the app at /files/….
workerThe scheduler (node worker.cjs). The app runs with SCHEDULER_IN_PROCESS=false.
mailpitCatches all email. The UI is at http://localhost:8025. Point SMTP_HOST at your relay for real delivery.
valkeyThe shared cache store (CACHE_PROVIDER=redis): rate limits and cache invalidations for every app replica and the worker. No persistence — nothing in it is the source of truth. Use CACHE_PROVIDER=postgres to run without it.
appThe web server, with a healthcheck on /api/config/public.

One command writes .env from deploy/compose/standalone.env.example with fresh secrets, builds and starts the stack, waits until it is healthy, and creates the first workspace:

bash
npm run stack -- up --email admin@example.com --org "Acme Inc"   # prints a generated password

By hand, the same steps are:

bash
cp deploy/compose/standalone.env.example .env   # replace every <generate>
docker compose --profile standalone up -d --build --wait
docker compose exec -e BOOTSTRAP_PASSWORD=… app node admin.cjs bootstrap --email admin@example.com --org "Acme Inc"

The standalone profile has some options:

  • PgBouncer: pass npm run stack -- init --pgbouncer, or add --profile pgbouncer and point DATABASE_URL at pgbouncer:5432. That runs transaction pooling for the app's queries. Migrations, LISTEN and the worker's leader lock need a real session, so MIGRATION_DATABASE_URL, REALTIME_DATABASE_URL and SCHEDULER_DATABASE_URL keep pointing at postgres:5432.
  • Host ports: use APP_PORT, MAILPIT_PORT and MINIO_CONSOLE_PORT.
  • Prebuilt image: set USERTASKS_IMAGE=registry.example.com/usertasks:1.2.3 and pass --no-build.

Other npm run stack commands:

CommandWhat it does
initWrites the env file. It never overwrites one unless you pass --force.
smokeRuns the smoke test.
down [--volumes]Stops the stack. --volumes also deletes the data.
compose -- <args>Runs any docker compose command with the profile's files.

Reference profiles

Each reference profile combines the providers for one environment and proves they work together. Run one with:

bash
npm run stack -- up --profile onprem|azure|gcp|aws [--no-build]
npm run stack -- smoke --profile onprem|azure|gcp|aws
npm run stack -- down --profile onprem|azure|gcp|aws --volumes

Reference profiles keep their env files in deploy/compose/.state/, which is git-ignored and gets fresh secrets. Each uses its own Compose project name and host port, so they don't collide with each other or with a dev server on port 3000.

On-premises (onprem)

This is the standalone stack on a Docker network with internal: true, so there is no route to the internet and nothing is published on the host. A passing run shows that no part of the app needs SaaS, a CDN or outbound calls: the smoke test also checks that the app container cannot reach registry.npmjs.org.

--keycloak adds Keycloak 26 (start-dev, realm deploy/compose/keycloak/usertasks-realm.json) as an OIDC identity provider (AUTH_SSO_PROVIDERS, preset keycloak). The doctor checks its discovery document from inside the isolated network.

For a real install, see On-premises setup.

Azure (azure)

The cloud services are replaced by emulators or by local stand-ins. In every case the app still uses its real SDKs.

Azure serviceLocal replacement
Blob StorageAzurite
Key VaultStand-in at https://usertasks.vault.azure.net. A Docker network alias, with TLS from a test CA in .state/certs and NODE_EXTRA_CA_CERTS. The app's managed identity gets tokens from IDENTITY_ENDPOINT, as on Container Apps. ENCRYPTION_KEY=secret://encryption-key is read from it at startup.
Communication Services EmailStand-in that accepts the send call and the status poll
Entra IDOIDC discovery stand-in
Container Apps JobThe tick container calls POST /api/cron/tick with CRON_SECRET every minute (SCHEDULER_PROVIDER=external).
Azure Monitor Logs IngestionStand-in for the DCR stream endpoint. The smoke test adds an azure_monitor destination with auth: managed_identity and checks its test event arrives.

The stand-ins are in deploy/compose/mocks/cloud-mocks.mjs.

Not emulated: Entra token auth for PostgreSQL (DATABASE_AUTH=azure; plain PostgreSQL with a password is used instead; the token path is tested against PostgreSQL with a fake token provider) and real sign-in through Entra ID.

To deploy for real, use deploy/azure/ (Bicep and Terraform) and Azure setup.

Google Cloud (gcp)

Google Cloud serviceLocal replacement
Cloud Storagefake-gcs-server (GCS_API_ENDPOINT)
Workload identityMetadata-server stand-in (GCE_METADATA_HOST). It supplies Application Default Credentials, as on Cloud Run.
Secret ManagerStand-in (GCP_SECRET_MANAGER_ENDPOINT). The client runs in its REST mode. ENCRYPTION_KEY and SMTP_PASSWORD are secret:// references resolved through it.
SMTP relayMailpit, configured to require a login
Google OIDCDiscovery stand-in
Cloud SchedulerThe tick container calls the tick endpoint with CRON_SECRET. In the cloud, Cloud Scheduler sends an OIDC token instead.
Cloud LoggingStand-in for entries:write (LOG_EXPORT_GCP_LOGGING_URL). The smoke test adds a gcp_logging destination with auth: workload_identity and checks its test event arrives.

Not emulated: Cloud SQL IAM database auth (DATABASE_AUTH=gcp; tested against PostgreSQL with a fake token provider).

To deploy for real, use deploy/gcp/ (Terraform) and Google Cloud setup.

AWS (aws)

AWS serviceLocal replacement
ECS task roleContainer-credentials stand-in over HTTPS (AWS_CONTAINER_CREDENTIALS_FULL_URI, test CA in .state/certs). Every AWS SDK call and SigV4 signature goes through the real credential chain.
S3, Secrets Manager, KMS, CloudWatch LogsLocalStack (community), seeded by deploy/compose/localstack/init-aws.sh: ENCRYPTION_KEY=secret://usertasks/encryption-key, KMS key alias/usertasks
SES v2Stand-in for SendEmail (AWS_ENDPOINT_URL_SESV2; LocalStack's community edition has SES v1 only)
CognitoOIDC discovery stand-in (preset cognito)
EventBridgeThe tick container calls the tick endpoint with CRON_SECRET, like an API destination
CloudWatch Logs exportThe smoke test adds an aws_cloudwatch_logs destination with auth: instance_role and auto_create, and reads its test event back from LocalStack

Not emulated: RDS IAM database auth (DATABASE_AUTH=aws) and ElastiCache IAM auth (CACHE_REDIS_AUTH=aws_iam): nothing local verifies those tokens. Both are tested with a fake signer against PostgreSQL and Valkey (__tests__/pg-auth-aws.test.ts, __tests__/cache-aws-iam.test.ts).

To deploy for real, use deploy/aws/ (Terraform) and AWS setup.

Smoke test

npm run stack -- smoke does four things:

  1. It creates a throwaway workspace, owner and API key (usertasks-admin bootstrap --api-key).
  2. It runs deploy/compose/smoke/smoke.mjs in a container on the deployment's network. That script needs no dependencies and only uses HTTP. It checks:
    • the app answers;
    • the API key works;
    • a task round trip;
    • password sign-in through the login page's server action, and the session it sets;
    • a file upload that is served back by /files;
    • the SSE realtime stream opens;
    • an invitation email reaches the mail sink (Mailpit or the ACS / SES stand-in);
    • for the cloud profiles, a log destination's test event reaches the cloud log stand-in (Azure Monitor, Cloud Logging, CloudWatch Logs).
  3. It runs usertasks-admin doctor --json and requires every check to pass, including the scheduler's last tick. It waits for that for up to three minutes.
  4. For onprem, it confirms there is no outbound internet.

The script also works against any running install:

bash
SMOKE_URL=https://tasks.example.com SMOKE_API_KEY=ut_… SMOKE_EMAIL=… SMOKE_PASSWORD=… \
SMOKE_ORG=<slug> SMOKE_SIGNIN_ACTION=<id> SMOKE_MAIL=skip node deploy/compose/smoke/smoke.mjs

To find the sign-in action id, look up the signInAction entry in .next/server/server-reference-manifest.json of the build.

Full instance migration test

scripts/e2e-instance-migration.ts takes a Supabase-backed install with the Northwind demo workspace (scripts/screenshots/seed-demo.ts) and moves it off Supabase:

  1. It runs usertasks-admin switch for auth → builtin, realtime → sse, scheduler → worker, storage → s3 (MinIO), email → smtp, secrets → db, and then the database → plain PostgreSQL. Each switch has a dry run first.
  2. It restarts the app with no Supabase variables at all.
  3. It checks the result:
    • the surface E2E (scripts/e2e-surfaces.ts: REST, MCP, CLI) passes, before and after;
    • the demo owner signs in with their old password;
    • files copied from Supabase Storage load through /files;
    • scheduled jobs run through the worker (app_cron.tick_runs);
    • log export ships new events to the demo's Splunk HEC destination;
    • row counts match;
    • usertasks-admin doctor passes;
    • the RLS semantics snapshot is identical before and after.

It is destructive for its source: use CI's stack or a second local Supabase stack (a copy of supabase/ with another project_id and ports). It refuses the default local ports unless E2E_ALLOW_DEFAULT_PORTS=1. The header of the script lists its environment variables.

Release gates

.github/workflows/release-gates.yml runs when started by hand (workflow_dispatch), on v* release tags, or from another workflow (workflow_call):

  • Reference profiles: standalone, standalone with PgBouncer, onprem (with Keycloak), azure, gcp and aws. Each one is stack up + smoke + down --volumes.
  • Instance migration: a local Supabase stack, plus PostgreSQL 16, MinIO and Mailpit as services, then scripts/e2e-instance-migration.ts.

A release ships only when all of them pass.

Infrastructure as code

DirectoryWhat it creates
deploy/azure/bicep, deploy/azure/terraformContainer Apps (web, a scheduled tick job, a manual migrate job), PostgreSQL Flexible Server with Entra-only auth, Blob Storage, Key Vault, Communication Services Email, Log Analytics, a container registry and one user-assigned managed identity
deploy/gcp/terraformCloud Run (service and migrate job), Cloud SQL for PostgreSQL 16 with IAM database auth, Cloud Storage, Secret Manager, a Cloud Scheduler OIDC tick and Artifact Registry
deploy/aws/terraformECS on Fargate behind an ALB (web service, admin and tick task definitions), RDS for PostgreSQL 16 with IAM database auth, ElastiCache for Valkey with an IAM user, S3, SES, Secrets Manager, a KMS key, CloudWatch log groups, an EventBridge Scheduler tick (or API destination, or worker service) and ECR

Each directory has a README with the deploy order and the one-time database grants. The templates are checked with terraform validate and az bicep build; they have not been applied to a live subscription or project yet.