.NET React Templates
Getting Started

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

ProviderDB_PROVIDERChoose it whenLocal requirement
SQLitesqliteA single application instance is enough and you want no external service to run or back up. The zero-dependency default.none
PostgreSQLpostgresThe usual production choice: multiple application instances, strong concurrency, wide managed-hosting support.Docker or Podman
MySQLmysqlYour organization already operates MySQL or MariaDB.Docker or Podman
SQL ServersqlserverYour 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 .env

Set one line in it:

DB_PROVIDER=postgres     # sqlite, postgres, mysql, or sqlserver

That 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 up

For a server provider this:

  • starts the same image the deployment's Kamal accessory runs — postgres:18-alpine, mysql:8.4, or mcr.microsoft.com/mssql/server:2022-latest;
  • creates the same next_saas database and the same unprivileged next_saas login, 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 localhost and 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 defaults

Switching 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:

VariableEffect
DEV_DB_PASSWORDLocal password. Defaults to Dev_Passw0rd!Local, deliberately fixed so a teammate reproduces the same local database. It is never a deployment credential.
DEV_DB_PORTPublished port, when 5432, 3306, or 1433 is already taken.
DEV_DB_ENGINEThe engine to run locally when the destination names a hosting arrangement rather than an engine. See Managed databases.
DOCKERContainer runtime to use. Otherwise docker, then podman.

3. Create the schema

cd MyApp
npm run migrate

The 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 database

To start over on empty data — whichever provider is running:

ASPNETCORE_ENVIRONMENT=Development ./scripts/reset-dev.sh --yes

To add repeatable example organizations and activity for screenshots to that same provider:

./scripts/seed-example-data.sh

This 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.json

Customize 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-secrets

The script:

  • writes Database, ConnectionStrings, and the Deployment policy for that provider into MyApp/appsettings.Production.json, backing up the previous file and leaving every other setting untouched;
  • runs scripts/preflight.sh --config-only to validate the result without printing secrets;
  • uploads APPSETTINGS_JSON and DB_PASSWORD as GitHub Actions secrets and sets the DB_PROVIDER repository 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=migrate

Switching 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-secrets

Local 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:

  1. create config/deploy.<name>.yml containing accessories: {};
  2. create .kamal/secrets.<name> with whatever the application needs;
  3. add no config/db/<name>/pre-deploy.sh;
  4. set DB_PROVIDER=<name> and put the managed connection string in MyApp/appsettings.Production.json yourself;
  5. set DEV_DB_ENGINE to the engine it actually runs, so ./scripts/dev-db.sh up still gives you the same engine locally;
  6. set Database__Provider explicitly, 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.