Choose a database
SQLite is the default. Switching to PostgreSQL or another RDBMS is a one-variable configuration change that applies to local development and production alike, not a pipeline fork.
Operations · Choose your database · Database and storage · Deployment
Choose your database is the step-by-step guide for a developer setting the template up. This page is the operator reference behind it.
The switch
The application is already provider-agnostic. MyApp/Configure.Db.cs branches on
Database:Provider to configure both OrmLite and EF Core from one connection string, and the
provider packages for SQLite and PostgreSQL are referenced by default. DB_PROVIDER supplies
Database:Provider when that setting is absent, so a recognized provider name is written down
once; an explicit Database__Provider still wins, which is how a destination named after
something other than a provider names its engine.
Only the deployment layer differs between providers, and it is selected with a
Kamal destination: the DB_PROVIDER repository variable, which
defaults to sqlite.
The same variable selects the local database. ./scripts/dev-db.sh up reads DB_PROVIDER from
.env and runs that provider from the accessory's own image, with the same next_saas database,
the same unprivileged login, and the same initializer scripts, writing the connection into the
private .env that Development startup applies over the source-controlled settings. Development therefore
happens on the engine that ships, and ./scripts/doctor.sh warns when the two disagree.
| Layer | sqlite | postgres | mysql | sqlserver |
|---|---|---|---|---|
| Kamal overlay | deploy.sqlite.yml | deploy.postgres.yml | deploy.mysql.yml | deploy.sqlserver.yml |
.kamal secrets | secrets.sqlite | secrets.postgres | secrets.mysql | secrets.sqlserver |
| Pre-deploy hook (config/db) | none | postgres/pre-deploy.sh | mysql/pre-deploy.sh | sqlserver/pre-deploy.sh |
| Accessory image | none | postgres:18-alpine | mysql:8.4 | mssql/server:2022-latest |
| Database and app login | n/a | init.sh initializer | image environment variables | pre-deploy.sh via sqlcmd |
| Production JSON profile | …sqlite… | …postgres… | …mysql… | …sqlserver… |
| Local database | none needed | ./scripts/dev-db.sh up | same | same |
Every server provider takes the same single DB_PASSWORD and connects as an unprivileged login
that owns only its own database, never as the cluster administrator.
Two provider-specific constraints are worth knowing before choosing SQL Server:
- It enforces a password policy — at least eight characters drawn from three of uppercase,
lowercase, digits, and symbols. A hex password satisfies only two categories and the container
refuses to start, so
configure-deployment.shrejects it first. Generate one with:
openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 40
It also needs roughly 2GB of memory, which rules out the smallest hosts.
- It runs no initializer scripts, unlike the PostgreSQL and MySQL images. Its pre-deploy hook
waits for the server to accept connections and then applies an idempotent
init.sqlthroughsqlcmd, and creates the data directory owned by uid 10001 because the image cannot write to a root-owned bind mount.
config/deploy.yml and .kamal/secrets-common hold everything shared by all providers. Kamal
deep-merges the destination overlay over the base file and reads .kamal/secrets-common followed
by .kamal/secrets.<provider>.
The Release workflow passes -d "$DB_PROVIDER" to every kamal command and runs
config/db/$DB_PROVIDER/pre-deploy.sh when that file exists. A provider with no database server
simply has no hook. Nothing in the workflow names a specific provider, so adding one never edits
the pipeline.
Start on SQLite
SQLite is the default because a first deployment then needs no external service. It verifies the domain, TLS, persistent volume, empty-state creation, login, files, jobs, and application APIs on their own before a database server is introduced.
cp config/appsettings.deploy.sqlite.example.json MyApp/appsettings.Production.json
# Customize every example value.
./scripts/configure-deployment.sh \
--provider sqlite \
--service my-app \
--repo owner/my-app \
--set-github-secretsIts constraints are real and deliberate:
- run exactly one application instance; SQLite cannot be shared by several containers;
- keep
/opt/docker/<service>/App_Dataon durable storage; - back up
App_Data, since there is no separate database server to replicate from.
Because of those constraints, Deployment.RequireNetworkDatabase is true by default and the
SQLite profile disables it explicitly. That override is the recorded decision to accept a
single-instance deployment.
Switch to PostgreSQL
export DB_PASSWORD="$(openssl rand -hex 32)"
./scripts/configure-deployment.sh \
--provider postgres \
--service my-app \
--repo owner/my-app \
--set-github-secretsA provider that runs a database server needs exactly one operator-managed secret, DB_PASSWORD.
.kamal/secrets.postgres maps it to both the unprivileged application login and the
POSTGRES_PASSWORD the postgres image requires to initialize its cluster superuser. The
application still connects as a non-superuser role, so a compromised application cannot administer
the cluster, but the two logins share one password value; give the superuser its own secret if you
need them to differ.
Both scripts read .env the same way config/deploy.yml does, so DB_PROVIDER and DB_PASSWORD
set there are picked up automatically and --provider defaults to $DB_PROVIDER.
configure-deployment.sh rewrites only the Database, ConnectionStrings, and Deployment
policy sections of the ignored production JSON, backs the file up first, runs
scripts/preflight.sh, and then uploads APPSETTINGS_JSON, the DB_PROVIDER variable, and
DB_PASSWORD. Every other setting in the file is preserved, so switching providers does
not disturb Stripe, SMTP, or product configuration.
The next release boots the <service>-postgres accessory, deploys, and runs the explicit
migration task. Setup PostgreSQL is the full
runbook, including the clean-Stripe-sandbox and reset steps.
Switching providers against an empty deployment is configuration-only. Switching after customer data exists requires a separately planned data migration, because application schema migrations create schema and do not copy rows between providers.
Add another provider
MySQL and SQL Server ship with the template. Adding a further RDBMS, or a managed database, is additive:
- add its OrmLite and EF Core packages, a
Database:Providerbranch, and aConfigureDb.TryNormalizeProvidername inMyApp/Configure.Db.cs; - add
config/deploy.<provider>.ymland.kamal/secrets.<provider>; - add
config/db/<provider>/pre-deploy.shif an accessory must be booted; - add
config/appsettings.deploy.<provider>.example.jsonand a branch inscripts/configure-deployment.sh; - add its image, connection string, readiness probe, and client shell to
scripts/dev-db.sh, so it also runs locally; - set the
DB_PROVIDERrepository variable to the new name.
For a managed database hosted elsewhere, steps 2 and 3 collapse to an overlay declaring no
accessory, and only the connection string in the production JSON changes. Step 5 then reduces to
setting DEV_DB_ENGINE to the engine that database actually runs, so ./scripts/dev-db.sh up
still provides the same engine locally.
Constraints worth knowing
Three Kamal behaviors shape this layout:
-
A destination config file must exist, and must parse as a YAML mapping.
kamal ... -d sqlitefails ifconfig/deploy.sqlite.ymlis absent. A file containing only comments is just as bad: it loads asfalseand fails withundefined method 'symbolize_keys' for false. That is why the SQLite overlay carries an explicitaccessories: {}rather than comments alone. The Release workflow checks the file exists first and fails with the list of available providers rather than a Kamal stack trace. -
A destination replaces the secrets file. With
-din play, Kamal reads.kamal/secrets-commonand.kamal/secrets.<destination>, and never plain.kamal/secrets. Shared values such asKAMAL_REGISTRY_PASSWORDandAPPSETTINGS_JSON_BASE64therefore live in.kamal/secrets-common. Anykamalcommand run without-dwill not see the provider's credentials. -
The merge replaces arrays. An overlay that redefines
volumesreplaces the base list rather than appending to it, so it must restate every entry it still needs. -
A destination scopes container names and labels. Containers are named
<service>-web-<destination>-<version>and labelleddestination=<destination>, and every Kamal removal filters on that label.kamal app remove -d sqlitetherefore cannot see a deployment made under a different destination, or under none at all. This matters when switching providers on a live host: deploying with a newDB_PROVIDERcreates a parallel set of containers rather than replacing the old ones, and both would then claim the same proxy host. Reset before switching providers rather than flipping the variable under a running deployment. -
Kamal deregisters a proxy service only while its container is still running. A route can therefore outlive the container that served it, leaving the host returning 502 and colliding with the next deployment.
scripts/reset-kamal-deployment.shsweeps every scope and additionally removes each<service>-<role>[-<destination>]registration by name, independently of container state, for exactly this reason.
A destination also changes the latest-<destination> image tag and the
.kamal/apps/<service>-<destination> directory on the host. It does not change the Kamal service
label, so an exact label=service=<service> filter still matches every scope.