Use containers for development
- Build images
- Run your image as a container
- Use containers for development
- Run your tests
- Configure CI/CD
- Deploy your app
Prerequisites
Work through the steps to build an image and run it as a containerized application in Run your image as a container.
Introduction
In this module, we’ll walk through setting up a local development environment for the application we built in the previous modules. We’ll use Docker to build our images and Docker Compose to make everything a whole lot easier.
Run a database in a container
First, we’ll take a look at running a database in a container and how we use volumes and networking to persist our data and allow our application to talk with the database. Then we’ll pull everything together into a Compose file which allows us to setup and run a local development environment with one command.
Instead of downloading PostgreSQL, installing, configuring, and then running the PostgreSQL database as a service, we can use the Docker Official Image for PostgreSQL and run it in a container.
Before we run PostgreSQL in a container, we’ll create a volume that Docker can manage to store our persistent data. Let’s use the managed volumes feature that Docker provides instead of using bind mounts. You can read all about Using volumes in our documentation.
Let’s create our data volume now.
$ docker volume create postgres-data
Now we’ll create a network that our application and database will use to talk to each other. The network is called a user-defined bridge network and gives us a nice DNS lookup service which we can use when creating our connection string.
$ docker network create postgres-net
Now we can run PostgreSQL in a container and attach to the volume and network we created above. Docker pulls the image from Hub and runs it for you locally.
In the following command, option -v
is for starting the container with the volume. For more information, see Docker volumes.
$ docker run --rm -d -v postgres-data:/var/lib/postgresql/data \
--network postgres-net \
--name db \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=example \
postgres
Now, let’s make sure that our PostgreSQL database is running and that we can connect to it. Connect to the running PostgreSQL database inside the container using the following command:
$ docker exec -ti db psql -U postgres
psql (14.5 (Debian 14.5-1.pgdg110+1))
Type "help" for help.
postgres=#
Press CTRL-D to exit the interactive terminal.
Update the application to connect to the database
In the above command, we logged in to the PostgreSQL database by passing the psql
command to the db
container.
Next, we’ll update the sample application we created in the Build images module.
Let’s add a package to allow the app to talk to a database and update the source files. On your local machine, open a terminal, change directory to the src
directory and run the following command:
$ cd /path/to/dotnet-docker/src
$ dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
In the src
directory, create a Models
folder. Inside the Models
folder create a file named Student.cs
and add the following code to Student.cs
:
using System;
using System.Collections.Generic;
namespace myWebApp.Models
{
public class Student
{
public int ID { get; set; }
public string LastName { get; set; }
public string FirstMidName { get; set; }
public DateTime EnrollmentDate { get; set; }
}
}
Save and close the Student.cs
file.
In the src
directory, create a Data
folder. Inside the Data
folder create a file named SchoolContext.cs
and add the following code to SchoolContext.cs
:
using Microsoft.EntityFrameworkCore;
namespace myWebApp.Data
{
public class SchoolContext : DbContext
{
public SchoolContext(DbContextOptions<SchoolContext> options) : base(options) { }
public DbSet<Models.Student>? Students { get; set; }
}
}
Save and close the SchoolContext.cs
file.
In the Program.cs
file located in the src
directory, replace the contents with the following code:
using Microsoft.EntityFrameworkCore;
using myWebApp.Models;
using myWebApp.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
// Add services to the container.
builder.Services.AddDbContext<SchoolContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("SchoolContext")));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
try
{
// add 10 seconds delay to ensure the db server is up to accept connections
// this won't be needed in real world application
System.Threading.Thread.Sleep(10000);
var context = services.GetRequiredService<SchoolContext>();
var created = context.Database.EnsureCreated();
}
catch (Exception ex)
{
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred creating the DB.");
}
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
Save and close the Program.cs
file.
In the appsettings.json
file located in the src
directory, replace the contents with the following code:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"SchoolContext": "Host=db;Database=my_db;Username=postgres;Password=example"
}
}
Save and close the appsettings.json
file.
In the Index.cshtml
file located in the src\Pages
directory, replace the contents with the following code:
@page
@model IndexModel
@{
ViewData["Title"] = "Home page";
}
<div class="text-center">
<h1 class="display-4">Welcome</h1>
<p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
</div>
<div class="row mb-auto">
<p>Student Name is @Model.StudentName</p>
</div>
Save and close the Index.cshtml
file.
In the Index.cshtml.cs
file located in the src\Pages
directory, replace the contents with the following code:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace myWebApp.Pages;
public class IndexModel : PageModel
{
public string StudentName { get; private set; } = "PageModel in C#";
private readonly ILogger<IndexModel> _logger;
private readonly myWebApp.Data.SchoolContext _context;
public IndexModel(ILogger<IndexModel> logger, myWebApp.Data.SchoolContext context)
{
_logger = logger;
_context= context;
}
public void OnGet()
{
var s =_context.Students?.Where(d=>d.ID==1).FirstOrDefault();
this.StudentName = $"{s?.FirstMidName} {s?.LastName}";
}
}
Save and close the Index.cshtml.cs
file.
Now we can rebuild our image. Open a terminal, change directory to the dotnet-docker
directory and run the following command:
$ docker build --tag dotnet-docker .
List your running containers.
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
146e1cb76e71 postgres "docker-entrypoint.s…" 25 minutes ago Up 25 minutes 5432/tcp postgresqldb
72bef28b1cd4 dotnet-docker "./myWebApp" 40 minutes ago Up 40 minutes 0.0.0.0:5000->80/tcp dotnet-app
Inspect the image column and stop any container that is using the dotnet-docker
image.
$ docker stop dotnet-app
dotnet-app
Now, let’s run our container on the same network as the database. This allows us to access the database by its container name.
$ docker run \
--rm -d \
--network postgres-net \
--name dotnet-app \
-p 5000:80 \
dotnet-docker
Let’s test that the application works and is connecting to the database. Using a web browser, access http://localhost:5000
. A page similar to the following image appears.
Connect Adminer and populate the database
You now have an application accessing the database, but the database contains no entries. Let’s connect Adminer to manage our database and create a database entry.
$ docker run \
--rm -d \
--network postgres-net \
--name db-admin \
-p 8080:8080 \
adminer
Using a web browser, access http://localhost:8080
.
The Adminer login page appears.
Specify the following in the login page and then click Login:
- System: PostgreSQL
- Server: db
- Username: postgres
- Password: example
- Database: my_db
The Schema: public
page appears.
In Tables and views
, click Students
. The Table: Students
page appears.
Click New item
. The Insert: Students
page appears.
Specify a LastName
, FirstMidName
, and EnrollmentDate
. Click Save
.
Verify that the student name appears in the application. Use a web browser to access http://localhost:5000
.
List and then stop the application, database, and Adminer containers.
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b76346800b6d adminer "entrypoint.sh docke…" 30 minutes ago Up 30 minutes 0.0.0.0:8080->8080/tcp db-admin
4ae70ac948a1 dotnet-docker "./myWebApp" 45 minutes ago Up 45 minutes 0.0.0.0:5000->80/tcp dotnet-app
75554c7694d8 postgres "docker-entrypoint.s…" 46 minutes ago Up 46 minutes 5432/tcp db
$ docker stop db-admin dotnet-app db
db-admin
dotnet-app
db
Better productivity with Docker Compose
In this section, we’ll create a Compose file to start our dotnet-docker app, Adminer, and the PostgreSQL database using a single command.
Open the dotnet-docker
directory in your IDE or a text editor and create a new file named docker-compose.yml
. Copy and paste the following contents into the file.
services:
db:
image: postgres
restart: always
environment:
POSTGRES_PASSWORD: example
volumes:
- postgres-data:/var/lib/postgresql/data
adminer:
image: adminer
restart: always
ports:
- 8080:8080
app:
build:
context: .
ports:
- 5000:80
depends_on:
- db
volumes:
postgres-data:
Save and close the docker-compose.yml
file.
The dotnet-docker
directory structure should now look like:
├── dotnet-docker
│ ├── src/
│ ├── Dockerfile
│ ├── .dockerignore
│ ├── docker-compose.yml
This Compose file is super convenient as we do not have to type all the parameters to pass to the docker run
command. We can declaratively do that using a Compose file.
We expose the ports so that we can reach the web server and Adminer inside the containers. We also map our local source code into the running container to make changes in our text editor and have those changes picked up in the container.
Another really cool feature of using a Compose file is that we have service resolution set up to use the service names. Therefore, we are now able to use “db” in our connection string. The reason we use “db” is because that is what we’ve named our PostgreSQL service as in the Compose file.
Now, to start our application and to confirm that it is running properly, run the following command:
$ docker-compose up --build
We pass the --build
flag so Docker will compile our image and then start the containers.
Now let’s test our application. Using a web browser, access http://localhost:5000
to view the page.
Shutting down
To stop the containers started by Docker Compose, press Ctrl+C in the terminal where we ran docker-compose up
. To remove those containers after they have been stopped, run docker-compose down
.
Detached mode
You can run containers started by the docker-compose
command in detached mode, just as you would with the docker command, by using the -d
flag.
To start the stack, defined by the Compose file in detached mode, run:
docker-compose up --build -d
Then, you can use docker-compose stop
to stop the containers and docker-compose down
to remove them.
The .env
file
Docker Compose will automatically read environment variables from a .env
file if it is available. Since our Compose file requires POSTGRES_PASSWORD
to be set, we create an .env
file and add the following content:
POSTGRES_PASSWORD=example
Now, update the compose file to use this variable.
services:
db:
image: postgres
restart: always
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?database password not set}
volumes:
- postgres-data:/var/lib/postgresql/data
adminer:
image: adminer
restart: always
ports:
- 8080:8080
app:
build:
context: .
ports:
- 5000:80
depends_on:
- db
volumes:
postgres-data:
POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?database password not set}
means that if the environment variable POSTGRES_PASSWORD
is not set on the host, Docker Compose will display an error. This is OK, because we don’t want to hard-code default values for the password. We set the password value in the .env
file, which is local to our machine. It is always a good idea to add .env
to .gitignore
to prevent the secrets being checked into the version control.
Build and run your application to confirm the changes are applied properly.
$ docker-compose up --build -d
Now let’s test our application. Using a web browser, access http://localhost:5000
to view the page.
Next steps
In the next module, we’ll take a look at how to write containerized tests in Docker. See:
Feedback
Help us improve this topic by providing your feedback. Let us know what you think by creating an issue in the Docker Docs GitHub repository. Alternatively, create a PR to suggest updates.