Notes and Talks/UJEP/NSQL/04

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:

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