57.10 Project 10: Node + MongoDB + mongo-express, pushed to a private registry
Why: the problem
Suppose we are at Nykaa and a team builds a user-profile app in Node.js with MongoDB. Testers want to see the data in a browser without learning mongosh, so we add mongo-express, a web UI for MongoDB. The image must not be public, because it is company code. So we push it to two private places: our own registry:2 container (like a small company registry on one server) and a private Docker Hub repository. Then a second machine (the test server) pulls the image by tag and runs the full stack with Compose, without the source code. This is the real flow of most teams: build once, push to a private registry, pull and run anywhere.
What: every term in plain English
- mongo-express — A web UI for MongoDB. Shows databases, collections and documents in the browser.
- Registry — A server that stores images. Docker Hub is a public one; registry:2 is the open source one we can run ourselves.
- Repository — One image name in a registry, like nykaa/profile-app, with many tags.
- Private repository — Only logged-in users with permission can pull it.
- Tag — A label on an image, like 1.0 or 2026-10-25. Never rely on latest.
- htpasswd — A file of user names and hashed passwords. registry:2 uses it for basic login.
- Insecure registry — A registry without HTTPS. Docker refuses it except on localhost unless you allow it in daemon.json.
- Digest — The sha256 id of an image. Pulling by digest always gets the exact same bytes.
How: step by step
- Write the Node profile app. Express + mongodb driver + a small HTML page.
- Run it with MongoDB and mongo-express. compose.yaml
- Start a private registry with a login. registry:2 + htpasswd.
- Tag and push to the private registry. localhost:5000/profile-app:1.0
- Push to a private Docker Hub repository. docker login, tag, push.
- Pull and run on another machine. compose.prod.yaml uses only the image.
- Clean up. down -v and remove images.
Architecture
Folder layout
profile-app/
public/index.html
server.js
package.json
package-lock.json
Dockerfile
.env
compose.yaml
compose.prod.yaml
registry/auth/htpasswd
Example 1: The Node profile app and Dockerfile
Goal. The Node profile app and Dockerfile.
package.json
{
"name": "profile-app",
"version": "1.0.0",
"scripts": { "start": "node server.js" },
"dependencies": { "express": "4.21.1", "mongodb": "6.10.0" }
}
server.js
const express = require("express");
const { MongoClient } = require("mongodb");
const client = new MongoClient(process.env.MONGO_URL);
const app = express();
app.use(express.json());
app.use(express.static("public"));
async function col() {
await client.connect();
return client.db("nykaa").collection("profiles");
}
app.get("/healthz", async (req, res) => {
try { await client.db("admin").command({ ping: 1 }); res.send("ok"); }
catch (e) { res.status(503).send(e.message); }
});
app.get("/api/profiles", async (req, res) => {
res.json(await (await col()).find({}, { projection: { _id: 0 } }).toArray());
});
app.post("/api/profiles", async (req, res) => {
const { name, skin } = req.body;
if (!name) return res.status(400).json({ error: "name is required" });
await (await col()).insertOne({ name, skin, at: new Date() });
res.status(201).json({ name, skin });
});
app.listen(3000, () => console.log("profile app on 3000, version " + process.env.APP_VERSION));
public/index.html
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Profiles</title></head>
<body>
<h1>Beauty profiles</h1>
<ul id="list"></ul>
<script>
fetch("/api/profiles").then(r => r.json()).then(rows => {
document.getElementById("list").innerHTML =
rows.map(p => "<li>" + p.name + " - " + (p.skin || "any") + "</li>").join("");
});
</script>
</body>
</html>
Dockerfile
FROM node:20-alpine
ARG APP_VERSION=dev
ENV NODE_ENV=production APP_VERSION=$APP_VERSION
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY public/ ./public/
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s CMD wget -qO- http://localhost:3000/healthz || exit 1
CMD ["node", "server.js"]
What each line does
express.static("public")— Serves index.html from the public folder.HTML in a code block— The page has tags like html and li. Inside a code block they show as text and are not run.ARG APP_VERSION— A build-time value. We pass the tag so the running app knows its version.ENV APP_VERSION=$APP_VERSION— Copies the build arg into the running container.projection _id: 0— Hides the Mongo _id in the JSON answer.
Green output
$ docker run --rm -v "$PWD":/app -w /app node:20-alpine npm install --package-lock-only
added 70 packages in 2s
$ docker build --build-arg APP_VERSION=1.0 -t profile-app:1.0 .
=> naming to docker.io/library/profile-app:1.0
Expected result. A small Node image that knows its own version.
Example 2: Run the app with MongoDB and mongo-express
Goal. Run the app with MongoDB and mongo-express.
compose.yaml
services:
app:
build:
context: .
args: { APP_VERSION: "1.0" }
image: profile-app:1.0
ports: ["3000:3000"]
environment:
MONGO_URL: mongodb://root:${MONGO_PASSWORD}@mongo:27017/?authSource=admin
depends_on:
mongo: { condition: service_healthy }
mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
volumes: [mongo-data:/data/db]
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
retries: 20
mongo-express:
image: mongo-express:1.0.2
ports: ["127.0.0.1:8081:8081"]
environment:
ME_CONFIG_MONGODB_URL: mongodb://root:${MONGO_PASSWORD}@mongo:27017/?authSource=admin
ME_CONFIG_BASICAUTH_USERNAME: tester
ME_CONFIG_BASICAUTH_PASSWORD: ${UI_PASSWORD}
depends_on:
mongo: { condition: service_healthy }
volumes:
mongo-data:
.env
MONGO_PASSWORD=change-me-mongo
UI_PASSWORD=change-me-ui
What each line does
127.0.0.1:8081:8081— The admin UI is reachable only from this machine, never from the internet.ME_CONFIG_BASICAUTH— mongo-express asks for a user name and password in the browser.ME_CONFIG_MONGODB_URL— How the UI connects to Mongo, by service name.build.args— Passes APP_VERSION to the Dockerfile ARG.
Green output
$ docker compose -p profiles up -d --build --wait
Container profiles-mongo-1 Healthy
Container profiles-app-1 Healthy
Container profiles-mongo-express-1 Started
$ curl -s -X POST localhost:3000/api/profiles -H 'Content-Type: application/json' -d '{"name":"Asha","skin":"oily"}'
{"name":"Asha","skin":"oily"}
$ curl -s -u tester:change-me-ui -o /dev/null -w '%{http_code}\n' localhost:8081/db/nykaa/profiles
200
Expected result. The app saves profiles, and testers browse them at localhost:8081 after logging in.
Example 3: Run a private registry with a login and push to it
Goal. Run a private registry with a login and push to it.
registry commands
mkdir -p registry/auth
docker run --rm --entrypoint htpasswd httpd:2.4 -Bbn ci change-me-reg > registry/auth/htpasswd
docker run -d --name registry -p 127.0.0.1:5000:5000 --restart unless-stopped \
-v registry-data:/var/lib/registry \
-v "$PWD/registry/auth":/auth:ro \
-e REGISTRY_AUTH=htpasswd \
-e REGISTRY_AUTH_HTPASSWD_REALM="Nykaa Registry" \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
registry:2
docker login localhost:5000 -u ci
docker tag profile-app:1.0 localhost:5000/nykaa/profile-app:1.0
docker push localhost:5000/nykaa/profile-app:1.0
curl -s -u ci:change-me-reg localhost:5000/v2/_catalog
curl -s -u ci:change-me-reg localhost:5000/v2/nykaa/profile-app/tags/list
What each line does
htpasswd -Bbn— Makes one bcrypt user line. We run it in a throwaway httpd container.-p 127.0.0.1:5000:5000— Only this machine can reach the registry. For other machines, put nginx with HTTPS in front.registry-data volume— Pushed image layers are stored here and survive restarts.docker tag— Gives the same image a second name that includes the registry host./v2/_catalog— The registry API lists all repositories.
Green output
$ docker login localhost:5000 -u ci
Password:
Login Succeeded
$ docker push localhost:5000/nykaa/profile-app:1.0
The push refers to repository [localhost:5000/nykaa/profile-app]
1.0: digest: sha256:7c41e2...b9 size: 1993
$ curl -s -u ci:change-me-reg localhost:5000/v2/_catalog
{"repositories":["nykaa/profile-app"]}
$ curl -s localhost:5000/v2/_catalog
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}
Expected result. The image is in our own registry, and nobody can read it without the password.
Example 4: Push to a private Docker Hub repository and pull on the test server
Goal. Push to a private Docker Hub repository and pull on the test server.
Docker Hub steps
# On Docker Hub website: Repositories > Create > name profile-app > Visibility: Private
docker login -u nykaa-demo # use an access token as the password
docker tag profile-app:1.0 nykaa-demo/profile-app:1.0
docker push nykaa-demo/profile-app:1.0
docker logout
compose.prod.yaml (on the test server)
services:
app:
image: nykaa-demo/profile-app:1.0
ports: ["80:3000"]
environment:
MONGO_URL: mongodb://root:${MONGO_PASSWORD}@mongo:27017/?authSource=admin
restart: unless-stopped
depends_on:
mongo: { condition: service_healthy }
mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
volumes: [mongo-data:/data/db]
restart: unless-stopped
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
retries: 20
volumes:
mongo-data:
What each line does
Visibility: Private— Free Docker Hub accounts get one private repository.access token— Create it in Docker Hub Account settings > Personal access tokens. Safer than the real password.image only, no build— The test server never sees the source code.ports 80:3000— On the server, users open port 80.No mongo-express— The admin UI stays only on developer machines.
Green output
test-server$ docker compose -f compose.prod.yaml pull
app Error pull access denied for nykaa-demo/profile-app, repository does not exist or may require 'docker login'
test-server$ docker login -u nykaa-demo
Login Succeeded
test-server$ docker compose -f compose.prod.yaml up -d --wait
Container prod-app-1 Healthy
test-server$ docker compose -f compose.prod.yaml logs app | tail -1
app-1 | profile app on 3000, version 1.0
Expected result. The private image is pulled only after login and runs on the test server with version 1.0.
Every command, step by step (plain docker and Compose)
-
Make folders
mkdir -p profile-app/public && cd profile-appWhat it does: Creates the folders. Add every file from the examples.
(no output) -
Lock file
docker run --rm -v "$PWD":/app -w /app node:20-alpine npm install --package-lock-onlyWhat it does: Creates package-lock.json.
added 70 packages -
Start the stack
docker compose -p profiles up -d --build --waitWhat it does: Builds and starts app, Mongo and mongo-express.
Container profiles-app-1 Healthy -
Add data
curl -s -X POST localhost:3000/api/profiles -H 'Content-Type: application/json' -d '{"name":"Asha","skin":"oily"}'What it does: Saves a profile.
{"name":"Asha","skin":"oily"} -
Open the UI
curl -s -u tester:change-me-ui -o /dev/null -w '%{http_code}\n' localhost:8081What it does: Checks mongo-express login works. Open it in the browser too.
200 -
Start the registry
docker run -d --name registry -p 127.0.0.1:5000:5000 -v registry-data:/var/lib/registry -v "$PWD/registry/auth":/auth:ro -e REGISTRY_AUTH=htpasswd -e REGISTRY_AUTH_HTPASSWD_REALM=Nykaa -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd registry:2What it does: Runs a private registry with login.
f3a9c1d2e4b5 -
Login and push
docker login localhost:5000 -u ci docker tag profile-app:1.0 localhost:5000/nykaa/profile-app:1.0 docker push localhost:5000/nykaa/profile-app:1.0What it does: Stores the image in our registry.
1.0: digest: sha256:7c41e2...b9 size: 1993 -
Remove local copy and pull back
docker image rm localhost:5000/nykaa/profile-app:1.0 profile-app:1.0 docker pull localhost:5000/nykaa/profile-app:1.0What it does: Proves the registry really has it.
Status: Downloaded newer image for localhost:5000/nykaa/profile-app:1.0 -
Pull by digest
docker pull localhost:5000/nykaa/profile-app@sha256:7c41e2...b9What it does: The exact same bytes, even if someone moves the tag.
Status: Image is up to date -
Clean up
docker compose -p profiles down -v docker rm -f registry && docker volume rm registry-data docker logout localhost:5000What it does: Removes the stack, the registry and the login.
Removing login credentials for localhost:5000
When it is red 1
Error response from daemon: Get "https://192.168.1.20:5000/v2/": http: server gave HTTP response to HTTPS client
Cause. The registry has no HTTPS and is not on localhost.
Fix, step by step
- Best: put nginx or Traefik with a certificate in front of the registry.
- Lab only: add the host to insecure-registries in /etc/docker/daemon.json and restart Docker.
When it is red 2
unauthorized: authentication required
Cause. You did not log in to that registry, or the login expired.
Fix, step by step
- Run docker login with the registry host.
- Check the user exists in htpasswd or has access on Docker Hub.
When it is red 3
denied: requested access to the resource is denied
Cause. The image name does not start with your Docker Hub user or organisation.
Fix, step by step
- Tag as youruser/profile-app:1.0, not profile-app:1.0.
- Check the repository exists and you have write access.
When it is red 4
mongo-express: Could not connect to database using connectionString
Cause. Mongo was not ready, or the URL password is wrong.
Fix, step by step
- Use depends_on service_healthy.
- Check ME_CONFIG_MONGODB_URL matches MONGO_PASSWORD and has authSource=admin.
When it is red 5
toomanyrequests: You have reached your pull rate limit.
Cause. Docker Hub limits anonymous pulls.
Fix, step by step
- docker login even for public images.
- Use a pull-through cache registry or a mirror like ECR Public.
Practice tasks
Easy Task 1: Stack
Why: Start.
What to do: Run app, Mongo and mongo-express.
- Use a VM or laptop with Docker and Compose.
- Create the profile-app folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -p profiles up -d --build --waitEasy Task 2: UI
Why: Admin.
What to do: Open mongo-express and find the profiles collection.
- Use a VM or laptop with Docker and Compose.
- Create the profile-app folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -s -u tester:change-me-ui localhost:8081/db/nykaa/ | grep -o profiles | head -1Easy Task 3: Registry
Why: Private.
What to do: Start registry:2 with htpasswd.
- Use a VM or laptop with Docker and Compose.
- Create the profile-app folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker run --rm --entrypoint htpasswd httpd:2.4 -Bbn ci change-me-reg > registry/auth/htpasswd
docker run -d --name registry -p 127.0.0.1:5000:5000 -v "$PWD/registry/auth":/auth:ro -e REGISTRY_AUTH=htpasswd -e REGISTRY_AUTH_HTPASSWD_REALM=R -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd registry:2Medium Task 4: Push
Why: Flow.
What to do: Tag and push 1.0 to localhost:5000.
- Use a VM or laptop with Docker and Compose.
- Create the profile-app folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker login localhost:5000 -u ci
docker tag profile-app:1.0 localhost:5000/nykaa/profile-app:1.0
docker push localhost:5000/nykaa/profile-app:1.0Medium Task 5: List tags
Why: Registry API.
What to do: List tags with curl.
- Use a VM or laptop with Docker and Compose.
- Create the profile-app folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -s -u ci:change-me-reg localhost:5000/v2/nykaa/profile-app/tags/listMedium Task 6: Version 1.1
Why: Releases.
What to do: Build with APP_VERSION=1.1 and push a new tag.
- Use a VM or laptop with Docker and Compose.
- Create the profile-app folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker build --build-arg APP_VERSION=1.1 -t localhost:5000/nykaa/profile-app:1.1 .
docker push localhost:5000/nykaa/profile-app:1.1Medium Task 7: Docker Hub
Why: Private repo.
What to do: Push to your private Docker Hub repository.
- Use a VM or laptop with Docker and Compose.
- Create the profile-app folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker login -u youruser
docker tag profile-app:1.0 youruser/profile-app:1.0
docker push youruser/profile-app:1.0Hard Task 8: Digest
Why: Exact bytes.
What to do: Find the digest and pull by it.
- Use a VM or laptop with Docker and Compose.
- Create the profile-app folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker image inspect localhost:5000/nykaa/profile-app:1.0 --format '{{index .RepoDigests 0}}'Hard Task 9: Rollback
Why: Ops.
What to do: On the test server, change the tag back to 1.0 and redeploy.
- Use a VM or laptop with Docker and Compose.
- Create the profile-app folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
sed -i 's/profile-app:1.1/profile-app:1.0/' compose.prod.yaml
docker compose -f compose.prod.yaml up -dEasy Task 10: Clean
Why: Habit.
What to do: Remove the stack, the registry and logins.
- Use a VM or laptop with Docker and Compose.
- Create the profile-app folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -p profiles down -v
docker rm -f registry
docker volume rm registry-data
docker logout localhost:5000Homework: try at home
Put nginx with a self-signed certificate in front of registry:2 on port 443, log in from a second VM using the server IP, push version 1.1, and pull it on the test server by digest.
Recap
- mongo-express gives testers a browser UI for MongoDB; keep it on 127.0.0.1 with a password.
- registry:2 with htpasswd is a simple private registry.
- Tag with the registry host, then push.
- Docker Hub private repos need login with an access token.
- Servers pull by tag or digest and never need the source code.
- Use HTTPS for any registry reached over the network.
FAQ
Q. Is registry:2 good for a company?
For small teams yes. Larger teams use Harbor, ECR, GitLab Registry or Artifactory for scanning and roles.
Q. Can I delete images from registry:2?
Enable REGISTRY_STORAGE_DELETE_ENABLED=true, delete by digest, then run garbage-collect.
Q. Where does Docker save my login?
In ~/.docker/config.json, or in a credential helper like the OS keychain.
Q. Is latest a good tag?
No. latest is just a name and can point anywhere. Use versions or dates.
Q. Can mongo-express edit data?
Yes, so protect it well. Use ME_CONFIG_OPTIONS_READONLY=true for testers.
Q. How do I use AWS ECR instead?
aws ecr get-login-password | docker login --username AWS --password-stdin with your registry URL, then tag and push the same way.
Interview questions
-
How do you share images privately between environments?
Model answer: Push to a private registry (ECR, Harbor, Docker Hub private, registry:2) and pull by tag or digest with login.
-
Why should you avoid the latest tag in production?
Model answer: It is mutable, so two servers can run different code and rollbacks are unclear.
-
What is the difference between a tag and a digest?
Model answer: A tag is a movable name; a digest is the immutable content hash.
-
How do you secure a self-hosted registry?
Model answer: HTTPS, authentication (htpasswd or token), network limits, and regular garbage collection and scanning.
-
How do you give CI permission to push without your password?
Model answer: Use an access token or a robot account with push rights only to that repository.
Quick quiz
Q1. Why use a private registry?
Company images must not be public, and a private registry controls who can pull and push.
Q2. What does docker tag do before a push?
Adds a name that includes the registry host and repository.
Q3. Why bind mongo-express to 127.0.0.1?
So the admin UI is never open to the internet.
Q4. What is pulling by digest?
Using @sha256:... so you always get the exact same image bytes.
Q5. Why use an access token for docker login?
It can be limited and revoked without changing your real password.
Samajla ka?
Revision card
Interview card