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.

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)
No comments yet — be the first to share your thoughts.