NoSQL in Docker: Redis, MongoDB, Neo4j
Example data model: a tiny social network – users, posts, follows, likes – in all three databases. Clients use the native shells redis-cli, mongosh, cypher-shell and Python drivers.
Docker Compose
Three servers, one network, one client container:
docker-compose.yml# Redis ➜ localhost:6379
# MongoDB ➜ localhost:27017
# Neo4j ➜ localhost:7474 (Browser), localhost:7687 (Bolt)
# Python ➜ utility container, no published port
#
# Service names (redis, mongo, neo4j) are DNS names on the Compose network,
# so the Python container talks to "redis:6379", "mongo:27017", "neo4j:7687".
name: nosql-docker # project name, used in container names and network name
services:
redis:
image: redis:7.4-alpine # Alpine is small
container_name: nosql-redis
# AOF (Append Only File) keeps the data across `docker compose restart`
command: ["redis-server", "--appendonly", "yes"]
ports:
- "6379:6379"
volumes:
- redis-data:/data # mounts redis-data volume to /data in the container
- ./scripts:/scripts:ro # mounts ./scripts on the host to /scripts in the container, read-only
healthcheck: # how to check if Redis is working
test: ["CMD", "redis-cli", "ping"] # Redis responds with "PONG"
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped # restart the container unless the user explicitly stopped it
mongo:
image: mongo:7.0
container_name: nosql-mongo
ports:
- "27017:27017" # connect to mongodb://localhost:27017
volumes:
- mongo-data:/data/db # ditto
- ./scripts:/scripts:ro # ditto
healthcheck:
test:
["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 20 # more tolerant than Redis, because MongoDB takes longer to start
start_period: 10s
restart: unless-stopped # ditto
neo4j:
image: neo4j:5.26-community
container_name: nosql-neo4j
ports:
- "7474:7474" # Neo4j browser (http://localhost:7474)
- "7687:7687" # Bolt protocol (bolt://localhost:7687)
environment:
# user/password; the password must have at least 8 characters
NEO4J_AUTH: neo4j/lecture123
# keep the Java heap footprint small
NEO4J_server_memory_heap_initial__size: 256m
NEO4J_server_memory_heap_max__size: 512m
NEO4J_server_memory_pagecache_size: 256m
volumes:
- neo4j-data:/data # ditto
- ./scripts:/scripts:ro # ditto
healthcheck:
test:
[
"CMD-SHELL", # the next line is a shell command
"cypher-shell -u neo4j -p lecture123 'RETURN 1' || exit 1",
]
interval: 10s
timeout: 10s
retries: 30
start_period: 30s # Neo4j takes a long time to start
restart: unless-stopped
# Python client container. Execute scripts inside it with
# docker compose run --rm python python redis_demo.py
python:
build: ./python
container_name: nosql-python
depends_on: # obviously
redis:
condition: service_healthy
mongo:
condition: service_healthy
neo4j:
condition: service_healthy
environment:
REDIS_HOST: redis
REDIS_PORT: "6379"
MONGO_URI: mongodb://mongo:27017
NEO4J_URI: bolt://neo4j:7687
NEO4J_USER: neo4j
NEO4J_PASSWORD: lecture123
volumes:
# bind mount: edit a .py file on the host
- ./python:/app
working_dir: /app
command: ["sleep", "infinity"] # keep the container running at the ready
restart: "no"
volumes:
redis-data:
mongo-data:
neo4j-data:
Topology:
host (terminal)
│ docker compose exec … docker compose run --rm python …
▼
┌──────────────────────────────────────────────────────────────────┐
│ Compose network: nosql-lecture_default │
│ │
│ nosql-redis:6379 nosql-mongo:27017 nosql-neo4j:7687 │
│ ▲ ▲ ▲ │
│ └─────────────┬───────┴─────────────────────┘ │
│ │ │
│ nosql-python (idle; runs one script at a time) │
└──────────────────────────────────────────────────────────────────┘
published to host: 6379 27017 7474, 7687
The service name is the hostname: inside the Compose network, connect to redis:6379, mongo:27017, and neo4j:7687 — never to localhost. localhost inside a container means that container itself.
Mission launch and first contact
bashdocker compose up -d # waits for health checks
docker ps
Three one-liners, one per database — this is the “it works” moment:
bashdocker compose exec redis redis-cli ping
docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping: 1})"
docker compose exec -T neo4j cypher-shell -u neo4j -p lecture123 "RETURN 'connected' AS status"
Expected:
PONG
{ ok: 1 }
status
"connected"
Open http://localhost:7474 in a browser for the Neo4j part.
bashdocker compose down # -v also deletes named volumes (database data)
Redis — key/value store
A huge, networked hash map. Keys are strings, values are one of several data structures (string, hash, list, set, sorted set). There is no query language — the commands are the API.
In the social network example: sessions, cached feeds, like counters, leaderboards, rate limiting.
Native client: redis-cli
Interactive:
bashdocker compose exec redis redis-cli
Then type:
PING
SET feed:alice "<html>cached feed for Alice</html>" EX 60
GET feed:alice
TTL feed:alice
TYPE feed:alice
HSET session:alice name Alice role member
HGET session:alice name
HGETALL session:alice
TYPE session:alice
INCR post:42:likes
INCR post:42:likes
GET post:42:likes
ZADD likes:leaderboard 2 post:42 5 post:7 1 post:9
ZREVRANGE likes:leaderboard 0 -1 WITHSCORES
ZINCRBY likes:leaderboard 4 post:42
ZREVRANGE likes:leaderboard 0 2 WITHSCORES
RPUSH feed:alice:posts post:7 post:9
LRANGE feed:alice:posts 0 -1
DEL feed:alice session:alice post:42:likes likes:leaderboard feed:alice:posts
KEYS '*'
Or run docker compose exec redis sh /scripts/redis.sh
redis.sh#!/bin/sh
# Redis CLI demo, executed INSIDE the redis container:
# docker compose exec redis sh /scripts/redis.sh
#
# Interactive alternative:
# docker compose exec redis redis-cli
#
# --no-raw keeps the output human-readable even though stdout is a pipe.
set -e
r() {
printf '\n> %s\n' "$*"
redis-cli --no-raw "$@"
}
# Test connectivity to the Redis server.
r PING
# Start clean so the script can be rerun.
r DEL feed:alice feed:alice:posts session:alice post:42:likes likes:leaderboard
# 1) String + expiration: a cached, rendered feed.
# key: feed:alice, value: "<html>cached feed for Alice</html>", expires in 60 seconds
r SET feed:alice "<html>cached feed for Alice</html>" EX 60
r GET feed:alice
# remaining time-to-live (TTL) in seconds
# -1 if no expiration, -2 if key does not exist
r TTL feed:alice
# the type of the value stored at key
r TYPE feed:alice
# 2) Hash: a small object with individually addressable fields.
# creates a hash at key session:alice with fields name and role
# session:alice
# name = Alice
# role = member
# returns the number of fields that were added (2)
r HSET session:alice name Alice role member
# get one field
r HGET session:alice name
# get all fields and values
r HGETALL session:alice
# the type of the value stored at key
r TYPE session:alice
# 3) Counter: atomic increment, the classic Redis use case.
# increments the value of key post:42:likes by 1 a few times
r INCR post:42:likes
r INCR post:42:likes
# get the value of the key
r GET post:42:likes
# the type of the value stored at key
r TYPE post:42:likes
# 4) Sorted set (ZSET): a leaderboard ordered by score.
# creates a sorted set at key likes:leaderboard with three members and their scores
# post:42 score 2
# post:7 score 5
# post:9 score 1
r ZADD likes:leaderboard 2 post:42 5 post:7 1 post:9
# get the members of the sorted set in descending order of score, with their scores
# 1) "post:7"
# 2) "5"
# 3) "post:42"
# 4) "2"
# 5) "post:9"
# 6) "1"
r ZREVRANGE likes:leaderboard 0 -1 WITHSCORES
# increment the score of post:42 by 4, making it the new top scorer
r ZINCRBY likes:leaderboard 4 post:42
# get the top 3 members of the sorted set in descending order of score, with their scores
r ZREVRANGE likes:leaderboard 0 2 WITHSCORES
# the type of the value stored at key
r TYPE likes:leaderboard
# 5) List: a cached timeline of posts, newest first.
# creates or appends to a list at key feed:alice:posts
# returns the list length
r RPUSH feed:alice:posts post:7 post:9
# returns the list items from index 0 to -1 (all items)
r LRANGE feed:alice:posts 0 -1
# the type of the value stored at key
r TYPE feed:alice:posts
# Cleanup: everything this demo created.
r DEL feed:alice feed:alice:posts session:alice post:42:likes likes:leaderboard
r KEYS '*'
Python
Run docker compose run --rm python python redis_demo.py
redis_demo.py"""Redis baby steps: connect, write, read, delete.
Run from the project root:
docker compose run --rm python python redis_demo.py
"""
import os
import redis
r = redis.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
decode_responses=True, # str instead of bytes -> nicer output
)
print("PING ->", r.ping())
# Start clean so the script can be rerun without changing the numbers.
r.delete("feed:alice", "session:alice", "post:42:likes", "likes:leaderboard")
# 1) Plain key/value with a TTL: a cached, rendered feed.
r.set("feed:alice", "<html>cached feed for Alice</html>", ex=60)
print("GET feed:alice ->", r.get("feed:alice"))
print("TTL feed:alice ->", r.ttl("feed:alice"))
# 2) Hash: a small object, fields can be read/written individually.
r.hset("session:alice", mapping={"name": "Alice", "role": "member"})
print("HGETALL session:alice ->", r.hgetall("session:alice"))
print("HGET session:alice name ->", r.hget("session:alice", "name"))
# 3) Counter: atomic increment, the classic Redis use case.
r.incr("post:42:likes")
r.incr("post:42:likes")
print("post:42:likes ->", r.get("post:42:likes"))
# 4) Sorted set: a leaderboard ordered by score.
r.zadd("likes:leaderboard", {"post:42": 2, "post:7": 5, "post:9": 1})
r.zincrby("likes:leaderboard", 4, "post:42")
print("top posts ->", r.zrevrange("likes:leaderboard", 0, 2, withscores=True))
# 5) Delete: everything this demo created.
r.delete("feed:alice", "session:alice", "post:42:likes", "likes:leaderboard")
print("keys left ->", r.keys("*"))
MongoDB — document store
JSON-like documents (BSON) live in collections; there is no schema, only the shape of the stored documents.
In the social network example: users, posts with embedded comments, activity feed documents, flexible product catalogs.
Native client: mongosh
Interactive:
bashdocker compose exec mongo mongosh
Then:
jsuse social
db.posts.insertOne({
_id: "post:1",
author: "alice",
text: "First post from the lecture",
tags: ["nosql", "intro"],
likes: 0,
comments: [{ author: "bob", text: "Nice!" }]
})
db.posts.findOne({ _id: "post:1" })
db.posts.find({ tags: "nosql" }, { _id: 1, author: 1, text: 1 })
db.posts.updateOne({ _id: "post:1" }, { $inc: { likes: 1 }, $push: { tags: "lecture" } })
db.posts.find({}, { _id: 1, likes: 1 }).sort({ likes: -1 })
db.posts.deleteOne({ _id: "post:1" })
db.posts.countDocuments({})
Or run docker compose exec mongo sh /scripts/mongo.js
mongo.js// MongoDB shell demo, executed INSIDE the mongo container:
// docker compose exec -T mongo mongosh --quiet /scripts/mongo.js
//
// Interactive alternative:
// docker compose exec mongo mongosh
//
// The document model needs no schema: the first insert defines the shape.
// same as `use social` in the interactive shell
const social = db.getSiblingDB("social");
const posts = social.posts;
print("== database: " + social.getName());
// Start clean so the script can be rerun.
posts.drop();
print("== dropped collection posts");
// 1) Insert one document, with an embedded array of comments.
posts.insertOne({
_id: "post:1",
author: "alice",
text: "First post from the lecture",
tags: ["nosql", "intro"],
likes: 0,
comments: [{ author: "bob", text: "Nice!" }],
created_at: new Date("2026-10-19T09:00:00Z"),
});
// Insert more documents.
posts.insertMany([
{
_id: "post:2",
author: "bob",
text: "MongoDB is fun",
tags: ["mongodb"],
likes: 3,
created_at: new Date("2026-10-19T09:05:00Z"),
},
{
_id: "post:3",
author: "carol",
text: "Graphs everywhere",
tags: ["neo4j"],
likes: 7,
created_at: new Date("2026-10-19T09:10:00Z"),
},
]);
// Query the collection:
print("\n== findOne({_id: 'post:1'})");
printjson(posts.findOne({ _id: "post:1" }));
// 2) Query an array field, return only selected fields.
print("\n== find({tags: 'nosql'}) - projection");
printjson(
posts.find({ tags: "nosql" }, { _id: 1, author: 1, text: 1 }).toArray(),
);
// 3) Atomic update: increment a counter, append to an array.
posts.updateOne(
{ _id: "post:1" },
{ $inc: { likes: 1 }, $push: { tags: "lecture" } },
);
print("\n== after $inc / $push");
printjson(posts.findOne({ _id: "post:1" }, { _id: 0, likes: 1, tags: 1 }));
// 4) Sorted query: most liked first.
print("\n== sorted by likes desc");
printjson(posts.find({}, { _id: 1, likes: 1 }).sort({ likes: -1 }).toArray());
// 5) Delete one document.
posts.deleteOne({ _id: "post:2" });
print("\n== documents left: " + posts.countDocuments({}));
print("== collections: " + social.getCollectionNames());
Python
Run docker compose run --rm python python mongo_demo.py
mongo_demo.py"""MongoDB baby steps: connect, insert, query, update, delete.
Run from the project root:
docker compose run --rm python python mongo_demo.py
"""
import os
from datetime import datetime, timezone
from pymongo import DESCENDING, MongoClient
client = MongoClient(
os.getenv("MONGO_URI", "mongodb://localhost:27017"),
serverSelectionTimeoutMS=5000,
)
db = client["social"]
posts = db["posts"]
print("ping ->", client.admin.command("ping"))
# Start clean so the script can be rerun.
posts.drop()
# 1) Insert one document. There is no schema: this shape is the schema.
posts.insert_one({
"_id": "post:1",
"author": "alice",
"text": "First post from the lecture",
"tags": ["nosql", "intro"],
"likes": 0,
# embedded document: comments belong to the post, no JOIN needed
"comments": [{"author": "bob", "text": "Nice!"}],
"created_at": datetime(2026, 10, 19, 9, 0, tzinfo=timezone.utc),
})
posts.insert_many([
{
"_id": "post:2",
"author": "bob",
"text": "MongoDB is fun",
"tags": ["mongodb"],
"likes": 3,
"created_at": datetime(2026, 10, 19, 9, 5, tzinfo=timezone.utc),
},
{
"_id": "post:3",
"author": "carol",
"text": "Graphs everywhere",
"tags": ["neo4j"],
"likes": 7,
"created_at": datetime(2026, 10, 19, 9, 10, tzinfo=timezone.utc),
},
])
# 2) Read one document by key.
print("find_one ->", posts.find_one({"_id": "post:1"}))
# 3) Query an array field, return only some fields (projection).
print("tag = nosql ->")
for doc in posts.find({"tags": "nosql"}, {"_id": 1, "author": 1, "text": 1}):
print(" ", doc)
# 4) Atomic update: increment a counter and append to an array.
posts.update_one(
{"_id": "post:1"},
{"$inc": {"likes": 1}, "$push": {"tags": "lecture"}},
)
print("after update ->", posts.find_one({"_id": "post:1"}, {"_id": 0, "likes": 1, "tags": 1}))
# 5) Sorted query: most liked first.
print("by likes ->")
for doc in posts.find({}, {"_id": 1, "likes": 1}).sort("likes", DESCENDING):
print(" ", doc)
# 6) Delete one document.
posts.delete_one({"_id": "post:2"})
print("documents left ->", posts.count_documents({}))
client.close()
Neo4j — graph store
Data is nodes (with labels and properties) and directed, typed relationships (also with properties). Relationships are stored as pointers, traversing them is cheap regardless of total graph size.
In the social network example: follows, friendships, likes.
Native client: cypher-shell
Interactive:
bashdocker compose exec neo4j cypher-shell -u neo4j -p lecture123
Then:
cypherCREATE (:User {name: 'Alice', city: 'Prague'});
CREATE (:User {name: 'Bob', city: 'Brno'});
MATCH (a:User {name: 'Alice'}), (b:User {name: 'Bob'})
CREATE (a)-[:FOLLOWS {since: 2026}]->(b);
MATCH (:User {name: 'Alice'})-[:FOLLOWS]->(f:User)
RETURN f.name AS follows;
MATCH (b:User {name: 'Bob'}) DETACH DELETE b;
Or run docker compose exec -T neo4j cypher-shell -u neo4j -p lecture123 -f /scripts/neo4j.cypher
neo4j.cypher// Neo4j / Cypher demo, executed INSIDE the neo4j container:
// docker compose exec -T neo4j cypher-shell -u neo4j -p lecture123 -f /scripts/neo4j.cypher
//
// Interactive alternative:
// docker compose exec neo4j cypher-shell -u neo4j -p lecture123
// or open http://localhost:7474 in a browser (Bolt URL: bolt://localhost:7687).
// Start clean so the script can be rerun.
MATCH (n) DETACH DELETE n;
// 1) Nodes. A label is a rough equivalent of a table; properties are a map.
CREATE (:User {name: 'Alice', city: 'Prague'});
CREATE (:User {name: 'Bob', city: 'Brno'});
CREATE (:User {name: 'Carol', city: 'Prague'});
CREATE (:User {name: 'Dave', city: 'Ostrava'});
// 2) Relationships. A relational DB needs a JOIN table for this.
MATCH (a:User {name: 'Alice'}), (b:User {name: 'Bob'})
CREATE (a)-[:FOLLOWS {since: 2026}]->(b);
MATCH (a:User {name: 'Alice'}), (b:User {name: 'Carol'})
CREATE (a)-[:FOLLOWS]->(b);
MATCH (a:User {name: 'Bob'}), (b:User {name: 'Carol'})
CREATE (a)-[:FOLLOWS]->(b);
MATCH (a:User {name: 'Bob'}), (b:User {name: 'Dave'})
CREATE (a)-[:FOLLOWS]->(b);
MATCH (a:User {name: 'Carol'}), (b:User {name: 'Dave'})
CREATE (a)-[:FOLLOWS]->(b);
// 3) A post and its edges.
CREATE (:Post {id: 'post:3', text: 'Graphs everywhere'});
MATCH (c:User {name: 'Carol'}), (p:Post {id: 'post:3'})
CREATE (c)-[:POSTED]->(p);
MATCH (b:User {name: 'Bob'}), (p:Post {id: 'post:3'})
CREATE (b)-[:LIKES]->(p);
// 4) Read: who does Alice follow?
MATCH (:User {name: 'Alice'})-[:FOLLOWS]->(f:User)
RETURN f.name AS follows;
// 5) Traverse two hops: friends of friends. The pattern is the query.
MATCH (:User {name: 'Alice'})-[:FOLLOWS]->()-[:FOLLOWS]->(fof:User)
RETURN DISTINCT fof.name AS friends_of_friends;
// 6) Read: who liked Carol's post?
MATCH (u:User)-[:LIKES]->(:Post {id: 'post:3'})
RETURN u.name AS liked_by;
// 7) Delete a node together with its relationships.
MATCH (b:User {name: 'Bob'}) DETACH DELETE b;
MATCH (n) RETURN labels(n) AS labels, count(*) AS nodes ORDER BY nodes DESC;
Python
Run docker compose run --rm python python redis_demo.py
Neo4j Browser
Open <http:/localhost:7474>, connect to `bolt:localhost:7687` with neo4j lecture123, then run:
cypherMATCH (n) RETURN n;
Switch from table to graph view and drag the nodes.
Three models, compared
| Fact | Redis | MongoDB | Neo4j |
|---|---|---|---|
| user Alice | HSET user:1 name Alice | {_id: "u1", name: "Alice"} | (:User {name: "Alice"}) |
| Alice follows Bob | SADD follows:alice bob | {$push: {following: "bob"}} | (a)-[:FOLLOWS]->(b) |
| posts of followed users | LRANGE feed:alice 0 -1 (precomputed) | find({author: {$in: [...]}}) | MATCH (a)-[:FOLLOWS]->()-[:POSTED]->(p) |
| like a post | INCR post:42:likes | $inc: {likes: 1} | MERGE (u)-[:LIKES]->(p) |
| friends of friends | not expressible (application-side sets) | $graphLookup (possible, awkward) | two-hop pattern, one line |
| schema | none, key naming convention | none (optional validators) | none, labels + properties |
| scaling story | cache/shard by key, in-memory | sharding by shard key, replication | causal cluster, read replicas |
Use cases:
Design the feed for 10 million users.
→ Redis cache + MongoDB source of truth.Recommend people you may know.
→ Neo4j.Count likes on a viral post.
→ RedisINCR, flushed to MongoDB periodically.Store the order history for 5 years.
→ MongoDB (or a relational DB, honestly).
Key message: polyglot persistence — one application, several stores, each doing what it is good at. The cost is consistency and operational complexity (CAP/BASE discussion in the first lectures).