Backend for the quiz platform "BEACON Q" (Play Store, App Store), based on Node.js and MongoDB.
As of 26 November 2025: β fully migrated to TypeScript β
Overview:
- β TypeScript
- β Rotating logs
- β MongoDB transactions (requires replica set)
- π Interactive API documentation (Swagger) - PARTIAL (in progress)
- β
Script for automatic schema generation (see
scripts/generateSchemas.js) - β
Docker
- MongoDB replica set (3 nodes)
- Automatic MongoDB initialization (keyfile, replica set initialization, user creation; see
docker-compose.ymlandmongo-init.js.example) - Automatic daily MongoDB backup (see
scripts/mongoDBDockerBackup.sh) - Replica set healthcheck verifying initialization, advertised member hostnames and primary election (see
scripts/mongoHealthcheck.sh) - Automatic restart for services
- Multi-stage build (see
Dockerfile) - Separate communication network for services
ESLint and Prettier are used for automatic pre-commit linting and formatting of code using the husky and lint-staged packages. (see package.json and .husky/pre-commit)
A Dockerfile and docker-compose.yaml are included to easily get both the Node.js and MongoDB replica set up and running:
- Wipe build and volumes:
docker-compose down --volumes - Run:
docker-compose up --build
However, you are also free to directly run the node app on your local machine (npm run dev), alongside running a MongoDB server on your machine.
This project relies on a number of environment variables and external credentials (Firebase service account JSON). For security reasons, never commit real credentials to the repository β use the provided .env.example as a template and keep your real .env and service account files outside source control.
Note: Node 18+ is required due to the mongoose package (>=9.0.0).
Use the provided .env.example to identify the expected key-values.
What to set (baseline):
PORTβ port the server listens on (default:3000).NODE_ENVβdevelopmentorproduction.MONGO_URIβ MongoDB connection stringMONGO_INITDB_ROOT_USERNAME,MONGO_INITDB_ROOT_PASSWORD,MONGO_INITDB_DATABASEβ used by Docker's mongo initialization.GOOGLE_APPLICATION_CREDENTIALSβ path to your Firebase service account JSON file (see below).
- Set up your Firebase project.
- Go to your project's page on Firebase Console.
- Go to the project settings (cog icon top-left) and select the tab "Cloud Messaging".
- Enable "Firebase Cloud Messaging API (V1)".
- Export a service account key file.
- Store the file somewhere in your project, then update the property "GOOGLE_APPLICATION_CREDENTIALS" in your
.envfile to point to it. (indicate the path to it)
Do NOT store this service account key file in your source control, e.g., do not commit it to your git repository.
- Recommended: include the file in your
.gitignore.
The docker-compose.yml config expects two files on the host (root project folder) when running the local Mongo replica set:
mongo-init.jsβ an initialization script mounted into the primary node (mongo1) at/docker-entrypoint-initdb.d/mongo-init.js.mongo-keyfileβ a shared key used by MongoDB for inter-node authentication. (mongo1, mongo2, mongo3)
- Generate the keyfile:
openssl rand -base64 756 > mongo-keyfile(official MongoDB docs) - If you are on a UNIX system (Linux, macOS), you can manually set the required permissions/mode:
chmod 400 mongo-keyfile- If you are on Windows, setting the permissions will not transfer to the UNIX context of Docker.
- To that end,
docker-compose.yamlincludes an initialization service "keyfile-init" to automatically adjust the permissions and owner of the file.
- Create a file
mongo-init.jsin the root of your project, then copy the provided template frommongo-init.js.exampleto this new file and adjust it to your needs.mongo-init.jsis only evaluated by the MongoDB image during the first initialization. Its purpose is to create a non-root database to store the project's data in, with separate authentication credentials and non-admin permissions for security reasons.- NOTE: this is NOT for creating the root (admin) mongo user. That step is done via our inclusion of the fields
MONGO_INITDB_ROOT_USERNAMEandMONGO_INITDB_ROOT_PASSWORDindocker-compose.yaml. Do not use the same username and password for both cases.
On Windows, docker-compose up can suddenly fail with an error like this, even though nothing else is using the port:
Error response from daemon: ports are not available: exposing port TCP 0.0.0.0:3000 -> 127.0.0.1:0: listen tcp 0.0.0.0:3000: bind: An attempt was made to access a socket in a way forbidden by its access permissions.
This is not the usual "port already in use" error (which would say something different). It means Windows/Hyper-V (used by WSL2, which backs Docker Desktop) has reserved the port in a dynamic TCP port exclusion range, so the OS refuses to let anything bind to it β including Docker. This can happen spontaneously after a Windows update, a Docker Desktop update, a WSL restart, or a reboot, since Hyper-V's NAT service (winnat) periodically re-allocates these ranges and can happen to grab a block that includes a port you need (e.g. 3000).
Diagnose it:
- Check if the port is actually held by a process (the "normal" case):
If nothing is listed, it's not a process holding the port β go to step 2.
netstat -ano | findstr ":3000"
- Check Windows' TCP port exclusion ranges:
If the port you need (e.g.
netsh interface ipv4 show excludedportrange protocol=tcp3000) falls inside one of the listedStart PortβEnd Portranges, that confirms it's being blocked by a reserved exclusion, not by another application.
Fix it:
- Stop your containers first:
docker-compose down - Restart the Windows NAT service, from an Administrator PowerShell/terminal:
net stop winnat net start winnat
- Re-run
netsh interface ipv4 show excludedportrange protocol=tcpto confirm the port is no longer in an excluded range. - Bring the stack back up:
docker-compose up -d
Note: wsl --shutdown alone is often not enough β the exclusion range is typically only released once winnat itself is restarted (or the machine is rebooted). If restarting winnat doesn't help, a full reboot will also clear it.
Depending on your machine's configuration, the server can fail to connect to external domains, such as Google Firebase.
To fix this, you need to disable ipv6: (example steps for Linux system)
- Append the following lines to
/etc/sysctl.conf:
net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1
net.ipv6.conf.lo.disable_ipv6 = 1
- Save the file.
- Run:
sysctl -p. - Verify if your change was succesful:
cat /proc/sys/net/ipv6/conf/all/disable_ipv6- Output should be "1"!
Symptoms
The stack starts and mongo1 reports healthy, but the Node.js app and the backup service keep failing to connect to MongoDB:
{"level":50,"msg":"Mongo connect attempt #1 failed: MongooseServerSelectionError: Server selection timed out after 30000 ms"}
{"level":50,"msg":"Mongo connect attempt #2 failed: MongooseServerSelectionError: Server selection timed out after 30000 ms"}
Cause
A MongoDB replica set persists its member list (the "replica set config") inside the database files β here, in the named volume mongo1-data.
rs.initiate() only runs once, during the very first initialization of that volume. From then on, the volume keeps whatever hostnames were in effect at that moment, and docker-compose down (without --volumes) does not reset it.
If the set was ever initialized with a hostname other than the container names β for example host.docker.internal, which is what a local docker-compose.override.yml uses β the config stays pinned to that hostname:
["host.docker.internal:27017", "host.docker.internal:27018", "host.docker.internal:27019"]The app can still reach the seed mongo1:27017, but the driver is then told that the primary lives at host.docker.internal:27017. When the mongo ports are not published on the host (i.e. no docker-compose.override.yml is present), nothing is listening there, so the driver never completes server selection and times out after 30 seconds.
This most often appears when moving the project between machines (e.g. Windows β macOS): docker-compose.override.yml is gitignored, so the working local setup does not travel with the repository, while the initialized volume still advertises the old hostnames.
The
mongo1healthcheck (scripts/mongoHealthcheck.sh) detects this condition. It initialises the replica set on the first run, and then fails as long as the advertised hostnames do not match the container names, or no primary has been elected.mongo1is therefore reported asunhealthy, and because the app and the backup service usedepends_on: service_healthy, they no longer start against an unusable database.The container remains
unhealthyuntil an election completes. On a first initialisation this delaysdocker-compose upby roughly 30β60 seconds β that is expected, not a hang, and it does not require runningdocker-compose upa second time. Docker keeps re-running the probe (everyinterval, up toretriestimes) until it succeeds, and only starts the app and the backup service once it does.The healthcheck only detects a stale config, it cannot repair one: a set advertised under the wrong hostnames stays
unhealthyanddocker-compose upfails once the retries are exhausted. Apply thers.reconfigfix below in that case.
Diagnose
The quickest signal is the health output of the container:
docker inspect --format '{{range .State.Health.Log}}exit={{.ExitCode}} out={{.Output}}{{end}}' "$(docker-compose ps -q mongo1)"A replica set advertising the wrong hostnames reports lines such as:
exit=1 out=unhealthy: replica set advertises [host.docker.internal:27017, ...] but expected [mongo1:27017, ...]
To confirm the advertised hostnames directly, with the stack running:
docker-compose exec -T mongo1 mongosh --quiet \
-u <username> -p <password> --authenticationDatabase admin \
--eval 'db.adminCommand({replSetGetConfig:1}).config.members.map(m => m.host)'Replace <username> / <password> with the values of MONGO_INITDB_ROOT_USERNAME / MONGO_INITDB_ROOT_PASSWORD from your .env.
Expected output:
[ 'mongo1:27017', 'mongo2:27018', 'mongo3:27019' ]
Anything else (for instance host.docker.internal:...) means the volume holds a stale config.
Fix (keeps your data)
Re-point the replica set at the container names in place, then restart the services:
docker-compose stop app mongo-backup
docker-compose exec -T mongo1 mongosh --quiet \
-u <username> -p <password> --authenticationDatabase admin \
--eval 'const cfg = db.adminCommand({replSetGetConfig:1}).config;
cfg.version++;
cfg.members[0].host = "mongo1:27017";
cfg.members[1].host = "mongo2:27018";
cfg.members[2].host = "mongo3:27019";
printjson(db.adminCommand({replSetReconfig: cfg, force: true}));'
docker-compose up -dThen confirm that all three members are healthy and that exactly one has been elected primary (the election can take a few seconds):
docker-compose exec -T mongo1 mongosh --quiet \
-u <username> -p <password> --authenticationDatabase admin \
--eval 'const s = db.adminCommand({replSetGetStatus:1});
print(JSON.stringify(s.members.map(m => ({name: m.name, state: m.stateStr, health: m.health})), null, 2))'Alternative fix (erases the database)
If you do not need to keep the data, delete the volumes so the replica set is initialized from scratch with the correct container names:
docker-compose down --volumes
docker-compose up -dHow to avoid it
- Keep the replica set advertised as
mongo1/mongo2/mongo3. The app reaches MongoDB through Docker's internal DNS; nothing else is needed for normal operation. - Never initialize a replica set with
host.docker.internal. A localdocker-compose.override.ymlexists to let host tools reach the ports of a set that is already advertised under container names β it is not a substitute for publishing ports. - If you need to connect from the host, prefer the hosts-file approach (Approach #2 below) over re-advertising the members.
- Remember that
docker-compose down(without--volumes) preservesmongo1-data, and therefore preserves a stale config.docker-compose down --volumesis the only command that resets it. - When moving a database between machines, prefer restoring a dump with
mongorestore(the format produced byscripts/mongoDBDockerBackup.sh) instead of copying raw database volumes, so the destination uses its own replica set config.
Internally (docker), the node service can connect to the MongoDB replica set using their assigned container names (mongo1, mongo2, mongo3).
This is facilitated by Docker's own DNS service, which maps each container name to its corresponding IP address in the Docker network.
Subsequently, the host machine does not know anything about these container names, as it cannot resolve the associated IP addresses for them.
The committed docker-compose.yml deliberately does not publish the MongoDB ports, and the replica set advertises its members under the internal container names (mongo1:27017, mongo2:27018, mongo3:27019). That is all the app and the backup service need, since they run on the same Docker network.
For local development you can add a local, gitignored docker-compose.override.yml that re-publishes the mongo ports (27017β27019) and re-advertises the members as host.docker.internal, so tools on the host can reach the replica set. Docker Compose merges it automatically on top of docker-compose.yml.
As .gitignore states, this file is local-only: "The server runs docker-compose.yml alone - this file must never be copied there."
β οΈ Warning: this override changes the hostnames the replica set is initialized with. If a volume is initialized while the override is active,mongo1-datawill permanently advertisehost.docker.internal, which breaks the app as soon as the override is absent. See MongoDB: Server selection timed out after 30000 ms. Only use the override against a set that was already initialized under the container names.
To connect from your host machine (using mongosh or MongoDB Compass):
First, you need to add host.docker.internal to your hosts file so your machine can resolve it:
- macOS/Linux: Add to
/etc/hosts(requires sudo):echo "127.0.0.1 host.docker.internal" | sudo tee -a /etc/hosts
- Windows: Add to
C:\Windows\System32\drivers\etc\hosts(requires admin):127.0.0.1 host.docker.internal
Then use this connection string:
mongodb://<user>:<password>@host.docker.internal:27017,host.docker.internal:27018,host.docker.internal:27019/?replicaSet=rs0&authSource=admin
Note: Even if you use localhost in the connection string, MongoDB's replica set will redirect you to the host.docker.internal hostnames internally, so the hosts file entry is required.
- Replace
<user>and<password>with the admin credentials we have set up in our.envfile. (MONGO_INITDB_ROOT_USERNAME,MONGO_INITDB_ROOT_PASSWORD)
The manual approach would be to add the IP mappings to our host's DNS resolution setup:
- Windows: edit the file
C:\Windows\System32\drivers\etc\hosts- open the file as admin, e.g., open Notepad as administrator
- Linux/macOS: edit the file
/etc/hosts- use
sudo
- use
- In the given
hostsfile, add a line for each container name, where we map them to the IP address on the host machine that can access our MongoDB server running in the Docker network. For example, in the case of "localhost", add the following entries:
127.0.0.1 mongo1
127.0.0.1 mongo2
127.0.0.1 mongo3
- Finally, to connect to our replica set, use the following connection string (using
mongoshor MongoDB Compass):mongodb://<user>:<password>@mongo1:27017,mongo2:27018,mongo3:27019/?replicaSet=rs0&authSource=admin- replace "user" and "password" with the admin credentials we have set up in our
.envfile. (MONGO_INITDB_ROOT_USERNAME,MONGO_INITDB_ROOT_PASSWORD)
- replace "user" and "password" with the admin credentials we have set up in our
- Note: this approach still requires the mongo ports to be published on the host. The committed
docker-compose.ymldoes not publish them, so you also need the localdocker-compose.override.ymldescribed in Approach #1 (the hosts file only makes the names resolvable, it does not open the ports).
The solution is to manually override your host machine's DNS resolution by editing its local hosts file. This file acts as a local, static DNS record keeper. You are telling your host machine: "When anything tries to look up the name mongo1, use this specific IP address."
- BEACON Q: Encouraging Regular Self-Testing via a Personalized and Gamified Quiz App
- High-Fidelity Simulation Pre-Briefing with Digital Quizzes: Using INACSL Standards for Improving Effectiveness
- Difficulty-Adjusted Quizzes: An Effectiveness Analysis
- Improving Long-Term Retention through Personalized Recall Testing and Immediate Feedback