Choose your database
The database provider is the first thing to customize, and it is one decision for both environments: DB_PROVIDER selects the database you run locally and the destination that deploys.
The database provider is a configuration choice rather than a code fork. MyApp/Configure.Db.cs
branches on Database:Provider to configure both OrmLite and EF Core, so SQLite, PostgreSQL,
MySQL, and SQL Server all run the same application code.
One variable selects it everywhere. DB_PROVIDER chooses the database scripts/dev-db.sh runs
on your machine, the Database:Provider the application uses, and the
Kamal destination the Release workflow deploys,
so you develop against the database you ship and there is no second setting to keep in step.
Decide before you create data you care about. Switching providers against an empty deployment is configuration-only; switching after customers exist needs a separately planned data migration, because schema migrations create schema and do not copy rows between engines.
Which provider
| Provider | DB_PROVIDER | Choose it when | Local requirement |
|---|---|---|---|
| SQLite | sqlite | A single application instance is enough and you want no external service to run or back up. The zero-dependency default. | none |
| PostgreSQL | postgres | The usual production choice: multiple application instances, strong concurrency, wide managed-hosting support. | Docker or Podman |
| MySQL | mysql | Your organization already operates MySQL or MariaDB. | Docker or Podman |
| SQL Server | sqlserver | Your organization standardizes on SQL Server. Budget roughly 2GB of memory for the container. | Docker or Podman |
1. Record the choice
.env is excluded from version control and holds your local operator settings:
cp .env.example .envSet one line in it:
DB_PROVIDER=postgres # sqlite, postgres, mysql, or sqlserverThat variable is read by scripts/dev-db.sh for local development, by
scripts/configure-deployment.sh when writing the production configuration, and as the
DB_PROVIDER repository variable that the Release workflow passes to every kamal command as
its destination. Nothing else selects a database.
2. Start the database locally
./scripts/dev-db.sh upFor a server provider this:
- starts the same image the deployment's Kamal accessory runs —
postgres:18-alpine,mysql:8.4, ormcr.microsoft.com/mssql/server:2022-latest; - creates the same
next_saasdatabase and the same unprivilegednext_saaslogin, using the same initializers the deployment uses —config/db/postgres/init.sh,config/db/sqlserver/init.sql, or the MySQL image's own environment variables; - publishes its usual port on
localhostand keeps its data in a named container volume; - writes the local connection into your private
.env.
SQLite starts no container. It writes the same file, and MyApp/App_Data/app.db is created on
first run.
It sets DB_PROVIDER and ConnectionStrings__DefaultConnection in .env, replacing those
assignments in place and leaving the rest of the file alone. The application reads .env
when it runs in Development, so any Key__Sub value there overrides the source-controlled
settings on your machine only:
.env your private overrides, excluded from version control
MyApp/appsettings.Development.json the SQLite default every clone starts from
MyApp/appsettings.json deployment-wide template defaultsSwitching your machine to PostgreSQL therefore changes nothing a teammate has to review. A
variable already set in your environment still wins over .env, so a one-off
DB_PROVIDER=sqlite dotnet run keeps working, an explicit Database__Provider wins over
DB_PROVIDER, and nothing in .env reaches a deployment.
Optional overrides:
| Variable | Effect |
|---|---|
DEV_DB_PASSWORD | Local password. Defaults to Dev_Passw0rd!Local, deliberately fixed so a teammate reproduces the same local database. It is never a deployment credential. |
DEV_DB_PORT | Published port, when 5432, 3306, or 1433 is already taken. |
DEV_DB_ENGINE | The engine to run locally when the destination names a hosting arrangement rather than an engine. See Managed databases. |
DOCKER | Container runtime to use. Otherwise docker, then podman. |
3. Create the schema
cd MyApp
npm run migrateThe first normal Development start also detects an empty database and creates the Identity schema, the SaaS schema, reference plans, and development users. Existing databases are never destructively recreated.
Continue with Run the template locally, then check the wiring at any time:
./scripts/doctor.sh # warns when the local engine and DB_PROVIDER disagree
./scripts/dev-db.sh status # provider, container state, and connection target
./scripts/dev-db.sh shell # psql, mysql, or sqlcmd against the local databaseTo start over on empty data — whichever provider is running:
ASPNETCORE_ENVIRONMENT=Development ./scripts/reset-dev.sh --yesTo add repeatable example organizations and activity for screenshots to that same provider:
./scripts/seed-example-data.shThis Development-only task is idempotent. It uses manager@email.com for the populated customer
experience and makes all six example organizations visible to admin@email.com.
4. Point production at the same provider
You can do this now or when you first deploy. Either way it is the same decision, not a second
one: --provider defaults to $DB_PROVIDER.
Start from the matching profile, which carries the database policy the provider implies, including whether migrations run automatically:
cp config/appsettings.deploy.postgres.example.json MyApp/appsettings.Production.jsonCustomize every example value — base URL, allowed hosts, product identity, SMTP, Stripe, and the
BootstrapAdmin block that creates your first platform administrator. The filename is excluded
from version control.
Generate the one database secret. A provider that runs a database server takes exactly one operator-managed password, used for both the application login and the image's own administrative account:
export DB_PASSWORD="$(openssl rand -hex 32)"SQL Server rejects that. Its password policy requires at least eight characters drawn from three of uppercase, lowercase, digits, and symbols, which a hex string does not satisfy. Use:
export DB_PASSWORD="$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 40)"Then apply the provider to the production configuration and upload the deployment secrets:
./scripts/configure-deployment.sh \
--service my-app \
--repo owner/my-app \
--set-github-secretsThe script:
- writes
Database,ConnectionStrings, and theDeploymentpolicy for that provider intoMyApp/appsettings.Production.json, backing up the previous file and leaving every other setting untouched; - runs
scripts/preflight.sh --config-onlyto validate the result without printing secrets; - uploads
APPSETTINGS_JSONandDB_PASSWORDas GitHub Actions secrets and sets theDB_PROVIDERrepository variable.
Drop --set-github-secrets to review the JSON first; the script then prints what still needs to
be uploaded.
5. Deploy and confirm
The Release workflow reads the DB_PROVIDER repository variable, runs
config/db/$DB_PROVIDER/pre-deploy.sh to boot or start the database accessory, and passes
-d "$DB_PROVIDER" to every kamal command. config/deploy.<provider>.yml deep-merges over
config/deploy.yml, and secrets resolve from .kamal/secrets-common plus
.kamal/secrets.<provider>.
After the release, https://your-domain/ready reports database and file-store readiness.
Choose a database covers the destination
mechanics, and Setup PostgreSQL is the full
runbook for replacing an existing deployment.
An empty database bootstraps itself on first start. Apply later migrations as a deliberate release step, before starting multiple instances:
cd MyApp
dotnet run --no-launch-profile --AppTasks=migrateSwitching providers later
Against an empty deployment, change DB_PROVIDER in .env and repeat steps 2 and 4:
./scripts/dev-db.sh up
./scripts/configure-deployment.sh --service my-app --repo owner/my-app --set-github-secretsLocal data for the previous provider stays in its own container volume until you remove it with
./scripts/dev-db.sh reset --provider <previous>. On the deployment side, reset before switching
rather than flipping the variable under a running deployment; a new destination creates a parallel
set of containers that would claim the same proxy host.
Managed databases
To deploy against RDS, Cloud SQL, Azure SQL, or any database you do not run yourself, add a destination that provisions nothing:
- create
config/deploy.<name>.ymlcontainingaccessories: {}; - create
.kamal/secrets.<name>with whatever the application needs; - add no
config/db/<name>/pre-deploy.sh; - set
DB_PROVIDER=<name>and put the managed connection string inMyApp/appsettings.Production.jsonyourself; - set
DEV_DB_ENGINEto the engine it actually runs, so./scripts/dev-db.sh upstill gives you the same engine locally; - set
Database__Providerexplicitly, because a destination named after a managed instance is not a provider name the application recognizes, so it cannot imply one.
For example, a managed PostgreSQL destination named postgres-managed uses
DB_PROVIDER=postgres-managed with DEV_DB_ENGINE=postgres and
Database__Provider=PostgreSql.
Troubleshooting
Cannot talk to docker
The daemon is not running, or your user cannot reach its socket. Start it, add yourself to the
docker group — sudo usermod -aG docker "$USER", then log in again — or run the script with
sudo, which hands .env back to you afterwards.
docker or podman is required to run the <provider> provider locally
Install a container runtime, or set DB_PROVIDER=sqlite to develop without a database server.
Database.Provider is postgres but DefaultConnection is a SQLite connection string
The provider and the connection string disagree. Startup fails deliberately rather than creating a
database nobody intended. Re-run ./scripts/dev-db.sh up locally, or
./scripts/configure-deployment.sh for a deployment.
An editor cannot open .env
The file is owned by root, because ./scripts/dev-db.sh was run through sudo. Take it back
with sudo chown "$USER" .env. To avoid needing sudo at all, add
yourself to the docker group — sudo usermod -aG docker "$USER", then log in again — or use
rootless Podman. The script hands the file back to the invoking user when it does run under
sudo.
The port is already in use
Another database is bound to it. Set DEV_DB_PORT and re-run ./scripts/dev-db.sh up.
doctor.sh warns that the local database and DB_PROVIDER disagree
You changed DB_PROVIDER without restarting the local database. Run ./scripts/dev-db.sh up.
The container exits at startup
./scripts/dev-db.sh up prints the last lines of its log. For SQL Server, the usual cause is a
DEV_DB_PASSWORD that fails the complexity policy.
Related documentation
Understand the template
Next SaaS provides the generic foundation that most self-service B2B SaaS products need. You replace the Acme example domain while retaining the reusable identity, billing, tenancy, entitlement, quota, and operations infrastructure.
Run the template locally
This page takes a clean checkout to a working development application and explains how to return to a clean state.