DEV Community

nausaf
nausaf

Posted on • Edited on

Use .env files for .NET local development configuration with VS Code

The Problem with .NET Development Configuration

Most ecosystems I use - Node.js, Docker/Compose, Terraform - expect configuration to come from environment variables.

In production, and other non-local environments, the hosting service loads data from configuration stores (e.g. Azure App Configuration and AWS AppConfig) and secret managers (like Azure Key Vault and AWS Secrets Manager) and sets these as environment variables before launching code.

For local development, config lives in .env files and is loaded automatically into environment variables by the framework or the launcher. Such .env files also typically contain secrets; this is mitigated by excluding them from the repo in .gitignore.

Either way - and in any environment - the app code only ever looks for its configuration in environment variables, which is in keeping with the philosophy of 12-factor apps.

Except in .NET!

In .NET, production configuration would still get loaded from environment variables that are injected by the hosting platform (e.g. from Azure App Configuration references or Key Vault references).

However, in local development, a .NET project loads configuration from an assortment of sources. This makes local .NET config stick out like a sore thumb when working in polyglot workspaces, where everything else just uses .env files.

For example, in a solution in which I have a .NET Minimal API alongside a Next.js frontend and a Docker Compose compose.yaml, both Next.js and Docker Compose can take in config data in .env files. Here, I would like to use an .env file such as the following to configure the .NET API also:

ASPNETCORE_ENVIRONMENT=Development
ASPNETCORE_URLS=http://localhost:3022
ALLOWED_CORS_ORIGINS=http://localhost:3020
ConnectionStrings__EShop=<redacted>
Enter fullscreen mode Exit fullscreen mode

Yet, instead of an .env file, we have the following sources:

  • appSettings.*.json configuration files. There are usually several of these, generated as part of boilerplate code.

  • .NET User Secrets Manager for secrets. This tool has its own quirks!

To complicate matters further, .NET projects also contain a scaffolded Properties/launchSettings.json. While originally meant for use in Visual Studio (the older product that predates VS Code), it still gets loaded and used when you launch your app through a Debug/Launch configuration in VS Code or on the command line using dotnet run. This too can provide config key/value pairs, sometimes without you realising it.

All these sources of development-time configuration obscure the effective source of a config value (there is a precedence order among the sources).

They are also discordant with how everything else is configured: via a single .env file.

For these reasons, I configure .NET projects in my polyglot workspaces to use .env files for local development, rather than the default sources above.

In this post, I'll show you how.

Note:

  • The solution given is primarily for VS Code. You may be able to adapt it for other IDEs such as JetBrains Rider or Visual Studio but those are outside the scope of this post.
  • To use a .env file with a .NET project, an alternative approach is to use dotenv-net. I explain at the end of this post why I don't like using it.

Step 1: Create .env files for VS Code launch configurations

Store all configuration, secret and non-secret, for a .NET project in a VS Code launch configuration in an .env file. I keep this next to launch.json in the .vscode folder:

I provide the path of such an .env file in envFile attribute in the launch configuration. For example, consider the compound launch configuration in launch.json given below. Note the envFile attributes:

{
  "version": "0.2.0",
  "compounds": [
    {
      "name": "Frontend/Backend",
      "configurations": [
        "Next.js: debug in full stack",
        ".NET: debug in full stack"
      ],
      "stopAll": true
    }
  ],
  "configurations": [
    {
      "name": ".NET: debug in full stack",
      "type": "coreclr",
      "request": "launch",
      "preLaunchTask": "build-backend",
      "program": "${workspaceFolder}/flowmazonbackend/flowmazonapi/bin/Debug/net9.0/flowmazonapi.dll",
      "cwd": "${workspaceFolder}/flowmazonbackend/flowmazonapi",
      "stopAtEntry": false,
      "envFile": "${workspaceFolder}/.vscode/flowmazonapi.env",
      "sourceFileMap": {
        "/Views": "${workspaceFolder}/Views"
      },
      "requireExactSource": false
    },
    {
      "name": "Next.js: debug in full stack",
      "type": "node",
      "request": "launch",
      "cwd": "${workspaceFolder}/flowmazonfrontend",
      "program": "${workspaceFolder}/flowmazonfrontend/node_modules/next/dist/bin/next",
      "args": ["dev", "--port", "3020"],
      "console": "integratedTerminal",
      "envFile": "${workspaceFolder}/.vscode/flowmazonfrontend.env",
      "serverReadyAction": {
        "pattern": "- Local:.+(https?://.+)",
        "uriFormat": "%s",
        "action": "debugWithChrome",
        "webRoot": "${workspaceFolder}/flowmazonfrontend"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

flowmazonapi.env configures the .NET minimal API and looks like this:

ASPNETCORE_ENVIRONMENT=Development
ASPNETCORE_URLS=http://localhost:3022
ALLOWED_CORS_ORIGINS=http://localhost:3020
ConnectionStrings__FlowmazonDB=<redacted>
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-prod-eu-west-2.grafana.net/otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=vscode_launch
OTEL_EXPORTER_OTLP_HEADERS=<redacted>
Enter fullscreen mode Exit fullscreen mode

VS Code takes all the key value pairs in the specified .env file and sets them as environment variables. In ASP.NET Core, these are read by the environment variable config source in the Generic Host. This is the last and therefore the highest priority config source in ASP.NET Core host's default sequence, so it overrides any other source providing the same keys.

So having an .env file to configure a .NET project in a VS Code launch configuration means it replaces, for local development, the canonical combination of appSettings.*.json files and .NET User Secrets Manager. For example, the ConnectionStrings__FlowmazonDB=<redacted> line in the .env file shown above equates to the following in appSettings.json:

{
  "ConnectionStrings": {
    "FlowmazonDB": "<connection string redacted>"
  }
}
Enter fullscreen mode Exit fullscreen mode

SECURITY NOTE: Please make sure that you have a .gitignore file in the workspace root and that it contains the following line:

*.env
Enter fullscreen mode Exit fullscreen mode

This would stop .env files, which may contain secrets such as connection strings for databases you use for local development, from getting checked in. Note that this, together with any secret scanning you might have in your online repo (such as GitHub Secret Scanning), guards against your local development secrets from leaking into your repo. It is important to be aware that, as with .NET User Secrets Manager, any secrets are still stored locally in plaintext.

All of your local development-time secrets and other configuration - for all projects and not just .NET if you have multiple ecosystems - are now in the .env files located in a single folder: in .vscode.

Moreover, all these colocated .env files are explicitly referenced in a single place: in launch configurations in launch.json in the .vscode folder.

If you have multiple launch configurations that need to be configured differently, you can define separate .env files for each of them.

Step 2: Provide an .env file to dotnet CLI commands

Even when you have one or more VS Code launch configuration to run a .NET project, you would likely still need to run the project in its folder on the shell, e.g. to generate or apply EF Core migrations from the DbContext in an API project.

My solution for that is to use direnv to load the .env file for the API from .vscode folder, as environment variables within the .NET project's own folder on the terminal.

You can set up direnv for this purpose like this:

  1. Set up direnv using the two steps given here:

    • Install direnv using your operating system's package manager.

      On Windows you could do this by installing WinGet if you don't have it already, then running the command winget install direnv in the shell.

    • Hook direnv into your shell.

      In Bash on Windows, I had to add line eval "$(direnv hook bash)" at the end of the file ~/.bashrc where ~ is my user profile folder C:\Users\<my windows login name>.

  2. Create a file named .envrc in the .NET project's folder with the following content:

    dotenv <relative path to folder containing env file>/<env file name>.env
    

    Normally you would have statements like export MY_VAR=<value> in the .envrc file to create environment variables. However, the dotenv command in the .envrc file above is telling direnv to go and load the contents of the specified .env file as environment variables.

  3. On the shell in your project's folder, run:

    direnv allow
    
  4. In general, an .envrc file for direnv should not be checked in. This is because it can directly contain environment variables and their values, some of which may be secrets. Even though this is not the case with the .envrc above, as a matter of good practice, I would add the following line to the .gitignore file in solution root:

    .envrc
    

Now whenever you cd into your .NET project's folder on the shell, direnv would run automatically, run the .envrc file in the folder, and load your .env file as environment variables.

This means you can run commands like dotnet run or dotnet ef migrations add or dotnet ef database update which require these configuration settings to run.

Also, when you move out of the folder (e.g. cd ..), the environment variables get unloaded by direnv. (Of course if you close that instance of the terminal, then too the loaded environment variables disappear).

Additional Tips

  • The *.env.template files shown in the screenshot above are pre-filled versions of the respective .env files with secrets redacted (much like the snippets shown above). I do check these in and, together with setup instructions in the project's wiki, they make it easy to recreate the configuration. The *.env files on the other hand obviously DO NOT get checked in.

  • If you want to use this technique, please make sure to delete the <UserSecretsId>a-guid-here</UserSecretsId> element that would be present in your csproj if you have previously used .NET User Secrets Manager with the project. While .env would be the highest-precedence source anyway, deleting <UserSecretsId> would avoid surprises down the line such as when you realise that you had forgotten to set certain secrets in .env and they had been coming from User Secret Manager all along.

Why I don't use dotenv-net

dotenv-net Nuget package can be used to load .env files into environment variables in code. For example:

using dotenv.net;

DotEnv.Load();

//then rest of Program.cs
var builder = WebApplication.CreateBuilder(args);
//...       
Enter fullscreen mode Exit fullscreen mode

DotEnv.Load() loads contents of .env file present in the directory of the running process as environment variables (you can also provide an alternate path as argument).

Next, when WebApplicaton.CreateBuilder(args) is called, the .NET configuration system loads config data from the default (or other configured) sources. This is when environment variables configuration provider loads as configuration the environment variables created by DotEnv.Load() earlier.

You can also wrap DotEnv into a configuration provider as this post shows.

The problem with this approach is essentially the mechanics: in ecosystems that are mainly configured using environment variables, .env files in local development are loaded by the launchers/orchestrators:

  • When Docker Compose launches apps/services for local testing, it reads key/value pairs from a .env file, then sets it as environment variables for every Docker container it launches. This is how your .NET app's container would receive its configuration.

  • When VS Code launches processes in a launch configuration - e.g. a .NET minimal API and a Next.js frontend - you can specify a separate .env file for each process. Thus each launch configuration can have its own set of .env files.

  • When the app is launched in a non-local environment, the host - Azure App Service, Kubernetes, ECS etc. - load configuration data from a configuration store and set it as environment variables before launching the process.

This way the processes launched for debugging or testing locally are completely oblivious to the source of configuration data and who loads/provides it. They don't know and don't care:

  • which .env files were used
  • whether .env files or some other configuration store (e.g. in Production) was used to retrieve configuration data
  • who loaded the config data

They just get the right set of environment variables to read at run time.

This makes processes really easy to configure, hence the popularity of this aspect of the 12-factor approach.

Hardcoding sources of configuration such as specific files - whether appsettings.*.json or .env - adds unnecessary complexity in my view.

It also again adds an exception for .NET behaviour in development - that the code is looking for specific files at startup if app.Environment.IsDevelopment() - when every other type of project in the polyglot workspace is probably only looking for environment variables when it runs.

All of this is not to say that there aren't situations where dotenv-net is the right solution. It's just that in general I prefer not to use it, for reasons given.

Top comments (0)