All articles

Run MongoDB in Docker and Connect from .NET

Skip the MongoDB installer — one docker run gives you a working database. From there the .NET driver takes over: documents, queries, atomic updates, transactions, and the small traps that catch you on the first try.

0 · log in to like, save & follow Share on LinkedIn Share on X

MongoDB used to mean an installer, a service, and a config file; Docker reduces it to one command that's identical on every machine and gone without residue when you're done. This post runs MongoDB 8 in a container and works with it from a .NET 10 app — typed documents, filters, atomic updates, LINQ, and indexes — with output verified from a real run.

Run MongoDB in Docker and Connect from .NET

The database: one command

docker run -d --name mongo-demo -p 27017:27017 -v mongodata:/data/db mongo:8

Flags that matter: -p 27017:27017 publishes Mongo's port to localhost; -v mongodata:/data/db puts the data in a named volume, so docker rm mongo-demo kills the container but keeps your data (recreate the container, the volume reattaches). For anything reachable beyond localhost, add root credentials with -e MONGO_INITDB_ROOT_USERNAME=... -e MONGO_INITDB_ROOT_PASSWORD=... — an open Mongo port is a rite of passage you can skip. Sanity checks: docker logs mongo-demo and docker exec -it mongo-demo mongosh.

The .NET side

dotnet add package MongoDB.Driver
var client = new MongoClient(Environment.GetEnvironmentVariable("MONGO_URL") ?? "mongodb://localhost:27017");
var db = client.GetDatabase("shop");
var products = db.GetCollection<Product>("products");

public class Product
{
    public ObjectId Id { get; set; }
    public required string Name { get; set; }
    public decimal Price { get; set; }
    public string[] Tags { get; set; } = [];
    public int Stock { get; set; }
}

MongoClient is thread-safe and pooled — register it as a singleton in DI, never per-request. There's no schema migration step and no EnsureCreated: databases and collections spring into existence on first write. That convenience cuts both ways — a typo'd collection name silently creates a new empty collection, so centralize the names.

Wire it into ASP.NET Core

MongoClient holds the connection pool, so it belongs in DI as a singleton — a new client per request quietly opens a new pool every time. IMongoDatabase and IMongoCollection<T> are thread-safe too, so they can be registered the same way and injected directly:

builder.Services.AddSingleton<IMongoClient>(_ =>
    new MongoClient(builder.Configuration.GetConnectionString("Mongo")));
builder.Services.AddSingleton(sp =>
    sp.GetRequiredService<IMongoClient>().GetDatabase("shop"));
builder.Services.AddSingleton(sp =>
    sp.GetRequiredService<IMongoDatabase>().GetCollection<Product>("products"));

Endpoints then ask for exactly what they need, with no repository ceremony in between:

app.MapGet("/products", async (IMongoCollection<Product> products) =>
    await products.Find(p => p.Stock > 0).SortBy(p => p.Price).ToListAsync());

One detail worth knowing: the current driver maps C# decimal to BSON Decimal128, so prices keep their exact precision and compare numerically — no [BsonRepresentation] attribute needed for money.

Insert and query

await products.InsertManyAsync(
[
    new Product { Name = "Mechanical keyboard", Price = 89.00m, Tags = ["input", "mechanical"], Stock = 14 },
    new Product { Name = "4K monitor", Price = 329.99m, Tags = ["display"], Stock = 6 },
    new Product { Name = "USB-C dock", Price = 149.50m, Tags = ["hub", "usb-c"], Stock = 0 },
]);

var inStockUnder200 = await products
    .Find(Builders<Product>.Filter.Gt(p => p.Stock, 0) & Builders<Product>.Filter.Lt(p => p.Price, 200m))
    .SortBy(p => p.Price)
    .ToListAsync();

Typed filter builders compose with &/|, get compile-time checking against your document class, and translate to native Mongo queries. LINQ works too (products.AsQueryable().Count(p => p.Tags.Contains("display"))) — use whichever reads better; builders expose more of Mongo's operator surface.

Verified output:

In stock under 200: Mechanical keyboard
Monitor stock after sale: 5
Display products: 1

Atomic updates: don't read-modify-write

The document database habit that prevents race conditions — update on the server:

var updated = await products.FindOneAndUpdateAsync(
    p => p.Name == "4K monitor",
    Builders<Product>.Update.Inc(p => p.Stock, -1),
    new FindOneAndUpdateOptions<Product> { ReturnDocument = ReturnDocument.After });

Inc, Set, Push, AddToSet execute atomically per document. Two concurrent sales both decrement correctly; the load-edit-replace alternative loses one of them. Single-document atomicity is Mongo's native consistency unit — model data so operations touch one document, and you rarely need multi-document transactions (which exist, but cost more).

Transactions need a replica set

Multi-document transactions exist, but a plain docker run mongo:8 cannot run them. Try it and the driver is blunt about why:

System.NotSupportedException: Standalone servers do not support transactions.

Transactions require a replica set — even one of a single node. Start the container with --replSet and initiate it once:

docker run -d --name mongo-rs -p 27017:27017 mongo:8 --replSet rs0
docker exec mongo-rs mongosh --quiet --eval 'rs.initiate({_id:"rs0", members:[{_id:0, host:"localhost:27017"}]})'

Setting host explicitly matters: rs.initiate() with no arguments records the container's hostname, which your host machine cannot resolve — the driver then hangs looking for a server it will never reach. Connect with mongodb://localhost:27017/?directConnection=true.

With that in place, a session wraps the operations:

using var session = await client.StartSessionAsync();
session.StartTransaction();
try
{
    await products.UpdateOneAsync(session, p => p.Name == "4K monitor",
        Builders<Product>.Update.Inc(p => p.Stock, -1));
    await orders.InsertOneAsync(session, new Order { Item = "4K monitor", Qty = 1 });
    await session.CommitTransactionAsync();
}
catch
{
    await session.AbortTransactionAsync();
    throw;
}

Every operation must be passed the session — forget it on one call and that call silently runs outside the transaction, which is worse than an error. Reach for this only when a single document genuinely can't hold the change; the atomic updates above cover most cases at a fraction of the cost.

Indexes

Same rules as SQL — queries without indexes scan:

await products.Indexes.CreateOneAsync(new CreateIndexModel<Product>(
    Builders<Product>.IndexKeys.Ascending(p => p.Price)));

CreateOneAsync is idempotent, so running it at startup is a reasonable lightweight strategy. Confirm usage the same way as SQL: .Find(...).Explain() in mongosh shows whether the plan says IXSCAN or COLLSCAN.

Aggregation: let the server do the maths

Pulling documents into the app to sum them wastes bandwidth and time. The aggregation pipeline groups server-side:

var summary = await products.Aggregate()
    .Match(p => p.Stock > 0)
    .Group(p => 1, g => new
    {
        Lines = g.Count(),
        Units = g.Sum(x => x.Stock),
        Value = g.Sum(x => x.Price * x.Stock)
    })
    .FirstOrDefaultAsync();

Verified output for the three products above:

lines=2 units=20 value=3225.94

Match first, then Group — the same instinct as putting WHERE before GROUP BY, and for the same reason: a Match that can use an index shrinks the set before the expensive stage runs.

Create a MongoDB Docker image with data baked in

The commands above start a stock image and add data at runtime. Sometimes you want a custom MongoDB image that ships with data — demo environments, integration-test fixtures, training labs. Mongo's official image runs anything in /docker-entrypoint-initdb.d/ on first start:

FROM mongo:8
COPY seed/products.js /docker-entrypoint-initdb.d/
// seed/products.js — runs once, on first container start with an empty data dir
db = db.getSiblingDB("shop");
db.products.insertMany([
  { name: "Mechanical keyboard", price: 89.0, stock: 14 },
  { name: "4K monitor", price: 329.99, stock: 6 },
]);
docker build -t shop-mongo .
docker run -d -p 27017:27017 shop-mongo

Every container from shop-mongo starts with the catalog present — no manual seeding step, identical data on every machine and CI runner. Two rules: init scripts run only when the data directory is empty (a persisted volume skips them on restart), and don't bake real customer data into images — images get pushed to registries, and registries get shared.

Compose file for real projects

The app + database pair belongs in docker-compose.yml, exactly like the SQL-based stacks elsewhere on this blog:

services:
  api:
    build: .
    environment:
      MONGO_URL: "mongodb://mongo:27017"     # service name = DNS name
    depends_on: [mongo]
  mongo:
    image: mongo:8
    volumes: ["mongodata:/data/db"]
volumes:
  mongodata:

Note the connection string inside the network uses the service name (mongo), not localhost — the most common first-compose stumble.

Make the app wait for Mongo

depends_on only waits for the container to start, not for Mongo to accept connections — which is why the first docker compose up on a cold machine often throws a connection error while Mongo is still initialising. A healthcheck plus service_healthy fixes it properly:

services:
  api:
    build: .
    environment:
      MONGO_URL: "mongodb://mongo:27017"
    depends_on:
      mongo:
        condition: service_healthy
  mongo:
    image: mongo:8
    volumes: ["mongodata:/data/db"]
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
      interval: 5s
      timeout: 5s
      retries: 10
volumes:
  mongodata:

Your app should still retry on startup — containers get rescheduled and databases restart — but this removes the predictable first-run failure.

Back up and restore the container's data

The named volume survives docker rm, but it does not survive a laptop. mongodump and mongorestore ship inside the image, so a backup is one command:

docker exec mongo-demo mongodump --db shop --archive=/tmp/shop.archive
docker cp mongo-demo:/tmp/shop.archive ./shop.archive

# restore into any container, anywhere
docker cp ./shop.archive mongo-demo:/tmp/shop.archive
docker exec mongo-demo mongorestore --archive=/tmp/shop.archive --drop

--drop replaces existing collections instead of merging into them — leave it out and a restore quietly doubles your documents.

When Mongo fits

Honest guidance: document databases shine when data is naturally document-shaped (a product with embedded variants, a form submission, an event payload), read mostly by key or simple filters, and evolving schema quickly. If your queries join entities constantly and consistency spans records, a relational database with EF Core remains the boring right answer. Plenty of systems run both — the skill is knowing which data goes where.

The runnable program is in the companion repository — one docker run, one dotnet run.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.