Since Microsoft decided with .NET Core that .NET should also support Linux, .NET Core has been an excellent basis for applications that run in Docker. This drastically simplifies the deployment of .NET Core applications. In combination with docker-compose, deployment consists of executing the following command:
docker-compose pull && docker-compose up -d
The first command ensures that the required versions of the Docker containers used are pulled from the registry. The second command ensures that all containers are started. Updated containers are restarted if necessary. The "-d" option ensures "detached" execution in the background. It couldn't be simpler. The configuration for docker-compose is taken from the docker-compose.yaml file. More on this later.
Configuration in Docker
However, deploying a .NET application in Docker raises the question of how to handle the configuration of the application. The way suggested by Microsoft is to use the appsettings.json file. Configuration values can, for example, be the access data to a database that is also hosted as a Docker container. In the case of MongoDB, for example, the host, port, username and password are required so that the application can connect to MongoDB. This looks as follows via appsettings.json:
"MongoDB": {
"host": "localhost",
"port": 27017,
"User": "mongo",
"Password": "secret"
},
This section in the appsettings.json file can be used to read out the configuration as follows:
var configurationBuilder = new ConfigurationBuilder()
.AddJsonFile("appsettings.json");
var configuration = configurationBuilder.Build();
var host = configuration.GetSection("MongoDb")["Host"];
Assert.That(host, Is.EqualTo("localhost"));
The configuration can also be placed in the IoC container so that classes receive their configuration objects via dependency injection.
Why appsettings.json in Docker is not a good idea
By using appsettings.json, however, you are leaving the path that is established in Docker. There, environment variables are used for the configuration of applications. This is very convenient because they can be specified in the docker-compose.yaml file. Furthermore, environment variables can also be passed on the command line with the docker run command if a Docker container is to be started.
The appsettings.json file is rather unsuitable because it is located inside the container. By compiling a container via the Dockerfile, the file ends up in the container. The question then arises as to how to make changes to the settings in a running Docker container. Although you can open a shell in the running container and edit the file there, this is tedious. Above all, the changes do not survive a redeployment of the container, because the appsettings.json is then transferred from the developer computer to the container again.
It is therefore much easier to store the configuration values in the docker-compose.yaml file or an .env file. This can be changed on the host system. The container is then restarted. We can therefore conclude that a .NET application hosted in Docker is best configured using environment variables. This could look as follows for the example of a MongoDB:
MONGODB_HOST=127.0.0.1
MONGODB_PORT=27017
MONGODB_USER=mongo
MONGODB_PASSWORD=secret
Accessing an environment variable from .NET Core
Under .NET, an environment variable can be accessed in the following way:
var host = Environment.GetEnvironmentVariable("MONGODB_HOST");
This would read the value of the variable "MONGODB_HOST" if it is defined in the environment. Otherwise, the value zero. We have already identified the first problem. Every time we access zero check. The call of the environment variable can be provided with a default value using the zero coalescing operator:
var result = Environment.GetEnvironmentVariable("UNKNOWN_ENV_VARIABLE") ?? "default value";
Nevertheless, a further problem remains, which also occurs when using the appsettings.json If you distribute these calls across your code base, the environment variables, in the example UNKNOWN_ENV_VARIABLE, are difficult to find. For this reason, I like to collect all configuration values in a class Config. But more on that later. First, let's talk about a better solution for reading in the environment variables.
The DotEnv NuGet package and the .env file
To summarize all environment variables that an application requires, a .env file can be used. The file name is actually ".env". Environment variables are stored in it line by line. With the help of the NuGet package DotEnv the file can then be read in in one go. The variables are transferred to the environment of the application and are available there again to be used with Environment.GetEnvironmentVariable to read in. The .env file can also be saved directly in the docker-compose.yaml file can be referenced.
env_file: .env
Alternatively, the individual variables can be entered as follows in the docker-compose.yaml file can be defined:
environment:
MONGO_INITDB_ROOT_USERNAME: mongo
MONGO_INITDB_ROOT_PASSWORD: secret
MONGO_INITDB_DATABASE: customers
This allows us to summarize all configuration data in one file. For access within the application, we then create a specific Config class, which reads the environment variables and offers the values as properties.
A separate Config class
The following listing shows an example of such a Config Class:
public static class Config
{
public static void Load() {
DotEnv.Fluent().Load();
}
public static void Load(string filename) {
DotEnv.Fluent().WithEnvFiles(filename).Load();
}
public static class MongoDb
{
public static string Host => GetEnvironmentVariable("MONGODB_HOST");
public static string Port => GetEnvironmentVariable("MONGODB_PORT");
public static string User => GetEnvironmentVariable("MONGODB_USER");
public static string Password => GetEnvironmentVariable("MONGODB_PASSWORD");
}
private static string GetEnvironmentVariable(string variable) {
var value = Environment.GetEnvironmentVariable(variable);
if (value != null) {
return value;
}
Load();
value = Environment.GetEnvironmentVariable(variable);
if (value != null) {
return value;
}
return "";
}
}
The class is then used as follows:
var host = Config.MongoDb.Host;
The idea here is as follows: within the static class Config static classes are also created locally. In the example MongoDB. Within these subclasses, properties are then created for all environment variables used by the application. The getters of the properties each use the method GetEnvironmentVariablelocally in the class Config is defined.
The procedure here is as follows: first an attempt is made to read the environment variable. If this is defined in the system, a string unequal to zero back. If this is the case, this value is used. If no environment variable with the corresponding name has yet been defined, DotEnv is instructed to use the .env File with Load Then an attempt is made to read the variable again. If a value is now available, it is returned. Otherwise, an empty string is returned in order to read the zero values.
In this way, the static properties can be accessed at the required points. This is a different approach than using configuration objects in the IoC container. And I know that some colleagues are allergic to static classes. That's why I'm dedicating a separate article to this topic in the next few days.
Example ASP.NET Core application
The ASP.NET Core application
The application used in the examples is developed with ASP.NET Core 8. It is a simple web service that receives customer data and stores it in a MongoDB database. The example has already been described in the article on Integration tests with Docker and the TestContainers project.
The Dockerfile file
To package an ASP.NET Core application in a Docker image, you create a Dockerfile. Explaining the mechanism in full would go too far here. You will find the example application shown here incl. Dockerfileunder the following link:
https://gitlab.com/ccd-akademie-gmbh/testcontainers
Is that Dockerfile and the application can be successfully packaged in an image, this build process can be used in the following step. For a production project, however, the image is uploaded to a private registry by the CI process and deployed from there.
The docker-compose.yaml file
Once you have created a Docker image of your application, Docker Compose can be used to start all the required containers. In our example, a MongoDB database is used. Therefore I create the following docker-compose.yaml:
version: "3.9"
services:
mongodb:
image: mongo:latest
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: mongo
MONGO_INITDB_ROOT_PASSWORD: secret
MONGO_INITDB_DATABASE: customers
volumes:
- ./docker/mongodb/db:/data/db
- ./docker/mongodb/dump:/data/db/dump
- ./docker/mongodb/mongod.conf:/etc/mongod.conf
restart: always
testcontainerexample:
build:
context: .
dockerfile: testcontainerexample/Dockerfile
ports:
- "80:8080"
- "443:443"
environment:
MONGODB_HOST: mongodb
MONGODB_PORT: 27017
MONGODB_USER: mongo
MONGODB_PASSWORD: secret
restart: always
depends_on:
- mongodb
The file contains two entries in the services section: mongodb and testcontainerexample. Both define a Docker container to be started. In the case of mongodb is displayed with the entry image specifies which image is to be used. In this case "mongo:latest". As no registry is explicitly specified, the Docker registry is implicitly used. All details about this image can be found at https://hub.docker.com/_/mongo to find. The environment variables for configuration are important in this section. MONGO_INITDB_ROOT_USERNAME, for example, is used to define which user is to be created in the instance.
The section testcontainerexample defines the container for the ASP.NET Core application. Here, no image is downloaded from a registry, but with build creates an image. To do this, the path to the Dockerfile is specified. Here the relative path points to the ASP.NET project. There docker build and thus create the image.
The configuration values for this container are also specified via environment variables. I would like to emphasize one of them in particular: "MONGODB_HOST: mongodb". In this way, the host name of the MongoDB instance to which the ASP.NET application is to connect is specified. The entry "mongodb" corresponds to the name of the services section for MongoDB. This is a detail of the Docker network configuration. Here, Docker Compose creates a hostname for each container so that the containers can communicate with each other via a specially created (virtual) network. At this point at the latest, it should become clear that it is not a good idea to store this information in the appsettings.json file. You would then have to ensure that the identifier remains consistent across the two files. It is much easier if everything is stored in the file docker-compose.yaml stands together.
Start the containers
The following command now starts all the required containers:
docker-compose pull && docker-compose up -d
The Docker images are downloaded as required using "pull" or created using "build". The entire process can be executed locally in the development environment or in production. This ensures that the same environment is tested locally during development that will later run in production.
Conclusion
It is easy to be misled by the Microsoft documentation and examples. If you use the standard way of the file appsettings.jsonthe use of Docker becomes unnecessarily complicated. The method presented here of configuring via environment variables is better suited to Docker.
Yes, you can also use environment variables in combination with the ConfigurationBuilder of .NET Core. Hierarchies are then created with double underscores. This is too much magic behind the scenes for me. It seems simpler to me to name the environment variables yourself as you see fit and then create a Config class that does the reading. Whichever way you choose: configure your .NET applications with environment variables, then it is easy to configure them in Docker.