ASP.NET MVC 5 (.NET Framework 4.8) proof of concept for moving a legacy application from
SQL Server to PostgreSQL. The same code runs against either database, and one setting in
Web.config chooses which. This guide takes you from a fresh clone to a working, seeded database
on both engines and shows how to confirm the application is really connected.
How the migrations are organised internally, and how to add a new one, is described in Database/README.md.
| Component | Notes |
|---|---|
| Windows 10 / 11 | .NET Framework 4.8 is part of Windows. |
| Visual Studio 2022 | Workload ASP.NET and web development. Community edition is fine. |
| SQL Server | Any edition: Developer, Express, or LocalDB (LocalDB ships with Visual Studio). |
| PostgreSQL 13 or newer | Windows installer from postgresql.org. Remember the password you give the postgres user. |
Optional: sqlcmd and psql on the PATH |
Only needed for the plain-SQL setup (section 4, option D). |
Both database servers must be running before you create the databases.
- Clone the repository and open
LegacyDatabaseMigrationPOC.slnin Visual Studio. - Restore NuGet packages: right-click the solution and choose Restore NuGet Packages
(Visual Studio also does this on the first build). The
packagesfolder is not in git. - Build the solution with Build > Build Solution. It must build cleanly before any database step.
Command-line alternative, from a Developer PowerShell for VS 2022 prompt:
nuget restore LegacyDatabaseMigrationPOC.sln
msbuild LegacyDatabaseMigrationPOC.csproj /t:Build /p:Configuration=DebugEverything you may need to change is in two places in Web.config.
<connectionStrings>
<add name="PostgresConnection" providerName="Npgsql"
connectionString="Host=localhost;Port=5432;Database=LegacyDatabaseMigrationPOC;Username=postgres;Password=root" />
<add name="SqlServerConnection" providerName="System.Data.SqlClient"
connectionString="Server=localhost,1433;Database=LegacyDatabaseMigrationPOC;Integrated Security=True;MultipleActiveResultSets=True" />
</connectionStrings>Keep the two name values exactly as they are; the code looks them up by name. Change the
connectionString values to match your machine:
| Your setup | connectionString |
|---|---|
| SQL Server default instance, Windows login (as shipped) | Server=localhost,1433;Database=LegacyDatabaseMigrationPOC;Integrated Security=True;MultipleActiveResultSets=True |
| SQL Server Express | Server=.\SQLEXPRESS;Database=LegacyDatabaseMigrationPOC;Integrated Security=True;MultipleActiveResultSets=True |
| LocalDB (installed with Visual Studio) | Server=(localdb)\MSSQLLocalDB;Database=LegacyDatabaseMigrationPOC;Integrated Security=True;MultipleActiveResultSets=True |
| SQL Server with a SQL login | Server=localhost,1433;Database=LegacyDatabaseMigrationPOC;User ID=sa;Password=YourPassword;MultipleActiveResultSets=True |
| PostgreSQL (as shipped) | Host=localhost;Port=5432;Database=LegacyDatabaseMigrationPOC;Username=postgres;Password=root |
| PostgreSQL, other port or user | Host=localhost;Port=5433;Database=LegacyDatabaseMigrationPOC;Username=appuser;Password=YourPassword |
The database named in the connection string does not need to exist yet: the setup step creates
it. The login must be allowed to create databases. A SQL Server sysadmin (your Windows account on a
local install) and the PostgreSQL postgres user both are.
<appSettings>
<add key="DatabaseProvider" value="PostgreSql" />
<add key="AutoMigrateDatabase" value="true" />
</appSettings>| Setting | Values | Meaning |
|---|---|---|
DatabaseProvider |
SqlServer or PostgreSql |
Which connection string the application uses. |
AutoMigrateDatabase |
true or false |
true: the first time the application touches a database it creates it, applies the migrations and seeds it. false: the application never changes the schema; create the databases with section 4. |
For a local setup you can skip this section: with no variables set, everything comes from
Web.config exactly as described above.
Any of the four settings can instead come from an environment variable of the same name. This
keeps passwords out of Web.config on a shared or deployed machine, and lets a build agent or
container point the application at its own database without editing any file.
| Variable | Overrides |
|---|---|
DatabaseProvider |
Which engine to use, SqlServer or PostgreSql. |
AutoMigrateDatabase |
true or false. |
SqlServerConnection |
The whole SQL Server connection string. |
PostgresConnection |
The whole PostgreSQL connection string. |
Rules:
- An environment variable always wins;
Web.configis the fallback for anything not set. - Set the whole connection string, not one part of it. There is no merging with
Web.config. - The override applies everywhere: the application, the migrations, the generated SQL scripts and
Database\Setup-Database.ps1. The application and the migration tooling can never end up pointing at different databases.
A process reads its environment when it starts, so in every case below the thing that hosts the application has to be restarted before it notices a change.
| Where you run it | Set them here | Applies after |
|---|---|---|
A PowerShell session (Setup-Database.ps1, IIS Express started from that shell) |
$env:PostgresConnection = '...' |
Immediately, for that shell and anything it starts. |
| Visual Studio, pressing F5 | Your Windows user account: [Environment]::SetEnvironmentVariable('PostgresConnection', '...', 'User') |
Restarting Visual Studio. F5 launches IIS Express from devenv.exe, so it inherits Visual Studio's environment, not your shell's. |
| Full IIS | IIS Manager > Application Pools > your pool > Advanced Settings > Environment Variables | Recycling the pool. On IIS versions without that setting, use a machine-wide variable and iisreset. |
| Azure App Service | Configuration > Application settings (not the Connection strings blade, which renames the variable) | Saving, which restarts the app. |
| Docker | -e PostgresConnection=..., or environment: in compose |
Recreating the container. |
| CI / build agents | The pipeline's secret variables | The next run. |
Database\set-local-env.example.ps1 sets all four variables in
one go. Copy it to Database\set-local-env.ps1 (that name is gitignored, so your credentials stay
out of the repository), edit the values, then dot-source it — the leading dot matters, it is what
applies the values to your current shell:
Copy-Item Database\set-local-env.example.ps1 Database\set-local-env.ps1
# edit the copy, then:
. .\Database\set-local-env.ps1 # this shell only
. .\Database\set-local-env.ps1 -Persist # also your user account, for Visual Studio
. .\Database\set-local-env.ps1 -Clear # remove them againOpen /DatabaseTest/Current or /DatabaseTest/Status: each provider is reported as coming from
Web.config or from an environment variable. The connection string itself is never displayed,
because it normally contains a password.
Nothing in the application talks to a secret store directly, and for the common Azure case nothing needs to.
Azure App Service — no code changes. Give the App Service a managed identity, grant it the Key Vault Secrets User role on your vault, then add an Application setting whose value is a Key Vault reference:
| Name | Value |
|---|---|
PostgresConnection |
@Microsoft.KeyVault(SecretUri=https://<your-vault>.vault.azure.net/secrets/PostgresConnection/) |
App Service resolves the reference at startup and hands the application a normal environment
variable, which is exactly what section 3.3 already reads. Put it under Application settings,
not the Connection strings blade: that blade renames the variable with a type prefix such as
POSTGRESQLCONNSTR_, which will not match. (A Connection strings entry does still work by a
different route — it overrides <connectionStrings> in Web.config, which is the fallback the
application uses when no environment variable is set.)
The same pattern works for anything that injects secrets as environment variables: Kubernetes secrets, Docker secrets exposed as variables, HashiCorp Vault Agent, or a CI pipeline's secret store.
Calling a vault from inside the application — needed only when nothing in front of the
application can inject the value — is a change to one method,
DatabaseProviderSettings.GetConnectionString in Data\DatabaseProvider.cs,
because every caller already resolves through it. Two things to get right if you go that way:
- Cache the result.
GetConnectionStringis called every time a context is constructed. Reading an environment variable is an in-memory lookup; a vault call is a network round trip, so fetch once at startup and hold it rather than calling per request. - Fail clearly. Decide up front whether an unreachable vault should stop the application or fall
back to
Web.config, and log which one happened.
Pick one option. Each one creates the database if it is missing, creates the dbo.Customers
table, records the migration in dbo.__MigrationHistory and seeds 10 sample customers. All of them
can be run again safely; a database that is already up to date is left alone.
- Press F5 in Visual Studio. The site opens on IIS Express at
https://localhost:44376/. - Browse to
https://localhost:44376/DatabaseTest/Migrate?provider=All.
That one page sets up both databases. Use ?provider=SqlServer or ?provider=PostgreSql to
set up only one. You should see:
== SqlServer: applying migrations ==
Migration completed.
== SqlServer ==
Connection string: SqlServerConnection
Database exists: True
Up to date: True
...
== PostgreSql: applying migrations ==
Migration completed.
cd <project folder>
.\Database\Setup-Database.ps1 # both databases
.\Database\Setup-Database.ps1 -Provider PostgreSql # one databaseThe script builds the project and runs the EF6 migrations with ef6.exe. If PowerShell refuses to
run scripts, use powershell -ExecutionPolicy Bypass -File .\Database\Setup-Database.ps1.
Open Tools > NuGet Package Manager > Package Manager Console and run one line per provider:
Update-Database -ConfigurationTypeName LegacyDatabaseMigrationPOC.Migrations.SqlServer.Configuration
Update-Database -ConfigurationTypeName LegacyDatabaseMigrationPOC.Migrations.PostgreSql.ConfigurationDatabase\Scripts\SqlServer\ and Database\Scripts\PostgreSql\ each contain Migrations.sql
(schema plus migration history) and SeedData.sql (sample rows). Create the database, then run the
two files in that order in SQL Server Management Studio or pgAdmin. Or let the script do all three
steps (needs sqlcmd and psql on the PATH):
.\Database\Setup-Database.ps1 -ApplyScriptsRun the site (F5) and open these pages. They return plain text, so they are easy to read and to paste into a bug report.
Configured Provider: PostgreSql (appSettings/DatabaseProvider)
Connection String: PostgresConnection
Database: LegacyDatabaseMigrationPOC
Connection Type: Npgsql.NpgsqlConnection
Server Version: 18.3
Connection Type shows which driver is really in use: Npgsql.NpgsqlConnection for PostgreSQL,
System.Data.SqlClient.SqlConnection for SQL Server. If the connection fails, the page shows the
full error instead. This page only opens a connection, so run section 4 first.
Configured provider: PostgreSql (appSettings/DatabaseProvider)
Auto-migrate on first use: True (appSettings/AutoMigrateDatabase)
== SqlServer ==
Connection string: SqlServerConnection
Database exists: True
Up to date: True
Local migrations: 202608190627453_InitialCreate
Applied: 202608190627453_InitialCreate
Pending: (none)
== PostgreSql ==
Connection string: PostgresConnection
Database exists: True
Up to date: True
Local migrations: 202609071103352_InitialCreate
Applied: 202609071103352_InitialCreate
Pending: (none)
Database exists: False, or anything under Pending, means section 4 has not been done for that
provider yet.
PostgreSql: 10 customer(s)
1 John Smith john.smith@example.com 2026-01-10 09:30 active
2 Sarah Johnson sarah.johnson@example.com 2026-01-11 10:15 active
...
10 Olivia White olivia.white@example.com 2026-01-19 13:40 active
Both providers list the same 10 rows. Without ?provider= the page uses the active provider.
| Page | Purpose |
|---|---|
/DatabaseTest/SqlServer |
Quick connectivity check for SQL Server only. |
/DatabaseTest/PostgreSql |
Quick connectivity check for PostgreSQL only. |
/DatabaseTest/Script?provider=PostgreSql |
Shows the SQL the migration runs (also works with SqlServer). |
SQL Server Management Studio:
USE LegacyDatabaseMigrationPOC;
SELECT MigrationId, ContextKey FROM dbo.__MigrationHistory;
SELECT * FROM dbo.Customers;pgAdmin, or psql -U postgres -d LegacyDatabaseMigrationPOC (the quotes are required):
select "MigrationId", "ContextKey" from dbo."__MigrationHistory";
select * from dbo."Customers";Expected: one migration row and 10 customers in each database.
- In
Web.config, setDatabaseProvidertoSqlServerorPostgreSql. - Save the file. IIS Express restarts the application; if a page still shows the old provider, stop debugging and press F5 again.
- Open
/DatabaseTest/Currentand checkConnection Type.
Nothing else changes. Both databases stay in place, so you can switch back and forth at any time.
| Symptom | Cause and fix |
|---|---|
| Build error This project references NuGet package(s) that are missing | Restore NuGet packages (section 2). |
| A network-related or instance-specific error occurred while establishing a connection to SQL Server | SQL Server is not running, or localhost,1433 is not reachable. Start the SQL Server (MSSQLSERVER) service. For Express or LocalDB use the matching connection string from section 3.1. When connecting by host,port, TCP/IP must be enabled in SQL Server Configuration Manager. |
| Login failed for user (SQL Server) | The login has no access. Use a sysadmin login, or grant the login access to the database. |
| PostgreSQL: No connection could be made or connection refused | The PostgreSQL service is not running or listens on another port. Check Services for postgresql-x64-<version> and the Port value in the connection string. |
| PostgreSQL: password authentication failed for user "postgres" | Wrong Password in PostgresConnection. |
The application ignores an edit to Web.config |
An environment variable of the same name is overriding it (section 3.3). /DatabaseTest/Current names the source it used. |
PostgreSQL: database "LegacyDatabaseMigrationPOC" does not exist on /DatabaseTest/Current |
That page only opens a connection and does not create the database. Run section 4 first. |
| Unable to update database to match the current model because there are pending changes | The model changed but a migration is missing for that provider. See "Adding a migration" in Database/README.md. |
Setup-Database.ps1: ef6.exe not found |
NuGet packages are not restored (section 2). |
Setup-Database.ps1: running scripts is disabled on this system |
Run it as powershell -ExecutionPolicy Bypass -File .\Database\Setup-Database.ps1. |
/DatabaseTest/... returns 404 |
The site is not running, or the URL is missing the port. Start with F5 and use the address Visual Studio opens. |
| Path | What it is |
|---|---|
Web.config |
Connection strings, DatabaseProvider, AutoMigrateDatabase. |
Data\AppDbContext.cs |
The EF6 context. Picks the connection string from DatabaseProvider. |
Data\DatabaseProvider.cs |
Resolves the engine and its connection string: environment variable first, Web.config second. |
Data\DatabaseMigrator.cs |
Runs, reports and scripts migrations for either provider. |
Migrations\SqlServer\, Migrations\PostgreSql\ |
One EF6 migration set per provider (they cannot be shared). |
Migrations\SeedData.cs |
The 10 sample customers, applied after every migration run. |
Controllers\DatabaseTestController.cs |
The /DatabaseTest/... diagnostic pages. |
Database\Setup-Database.ps1 |
Command-line setup, SQL script generation, plain-SQL setup. |
Database\Scripts\ |
Generated SQL for both engines plus seed scripts. |
Database\README.md |
How the dual migrations work and how to add a new migration. |