All articles

Run MongoDB in Docker and Connect from .NET

MongoDB 7 in a container with one command, then typed documents, filters, atomic updates, LINQ, and indexes from the .NET driver — verified end to end.

0 · log in to like, save & follow Share on LinkedIn Share on X
Run MongoDB in Docker and Connect from .NET

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 7 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:7

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.

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).

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.

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:7
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:7
    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.

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.

Comments (0)

Log in to join the conversation.

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