feat: add optional MongoDB replica deployment (#765)

This commit is contained in:
Thomas Durieux
2026-07-30 02:28:19 +02:00
committed by GitHub
parent a8733af4f4
commit debd83c079
11 changed files with 512 additions and 7 deletions
+1
View File
@@ -3,6 +3,7 @@ build
/repositories /repositories
repo/ repo/
db_backups db_backups
/secrets/mongo-replica-keyfile
message.txt message.txt
# macOS # macOS
+3
View File
@@ -79,6 +79,9 @@ docker-compose up -d
**4. Open** <http://localhost:5000>. The port can be changed in `docker-compose.yml`; putting Anonymous GitHub behind nginx is recommended for HTTPS. **4. Open** <http://localhost:5000>. The port can be changed in `docker-compose.yml`; putting Anonymous GitHub behind nginx is recommended for HTTPS.
For an optional remote, hidden, delayed MongoDB replica and backup source, see
the [MongoDB replication guide](docs/mongodb-replication.md).
</details> </details>
## Scope of anonymization ## Scope of anonymization
+17
View File
@@ -0,0 +1,17 @@
services:
mongodb:
entrypoint:
- /bin/bash
- /opt/anonymous-github/mongodb-replica-entrypoint.sh
command:
- --quiet
- --replSet
- rs0
- --keyFile
- /tmp/mongo-replica-keyfile
- --bind_ip_all
environment:
MONGO_REPLICA_KEYFILE_PATH: /run/secrets/mongo-replica-keyfile
volumes:
- ${MONGO_REPLICA_KEYFILE:-./secrets/mongo-replica-keyfile}:/run/secrets/mongo-replica-keyfile:ro
- ./scripts/mongodb-replica-entrypoint.sh:/opt/anonymous-github/mongodb-replica-entrypoint.sh:ro
+38
View File
@@ -0,0 +1,38 @@
name: anonymous-github-mongo-secondary
services:
mongodb-secondary:
image: ${MONGO_IMAGE:-mongo:latest}
restart: unless-stopped
entrypoint:
- /bin/bash
- /opt/anonymous-github/mongodb-replica-entrypoint.sh
command:
- --quiet
- --replSet
- rs0
- --keyFile
- /tmp/mongo-replica-keyfile
- --bind_ip_all
environment:
MONGO_REPLICA_KEYFILE_PATH: /run/secrets/mongo-replica-keyfile
volumes:
- mongodb_secondary_data:/data/db
- ${MONGO_REPLICA_KEYFILE:-./secrets/mongo-replica-keyfile}:/run/secrets/mongo-replica-keyfile:ro
- ./scripts/mongodb-replica-entrypoint.sh:/opt/anonymous-github/mongodb-replica-entrypoint.sh:ro
ports:
- ${MONGO_BIND_ADDRESS:-127.0.0.1}:27017:27017
healthcheck:
test:
- CMD
- mongosh
- --quiet
- --eval
- "db.adminCommand('ping')"
interval: 10s
timeout: 10s
retries: 12
start_period: 10s
volumes:
mongodb_secondary_data:
+2 -2
View File
@@ -79,7 +79,7 @@ services:
retries: 5 retries: 5
mongodb: mongodb:
image: mongo:latest image: ${MONGO_IMAGE:-mongo:latest}
restart: on-failure restart: on-failure
environment: environment:
MONGO_INITDB_ROOT_USERNAME: $DB_USERNAME MONGO_INITDB_ROOT_USERNAME: $DB_USERNAME
@@ -87,7 +87,7 @@ services:
volumes: volumes:
- mongodb_data_container:/data/db - mongodb_data_container:/data/db
ports: ports:
- 127.0.0.1:27017:27017 - ${MONGO_BIND_ADDRESS:-127.0.0.1}:27017:27017
command: --quiet command: --quiet
healthcheck: healthcheck:
test: test:
+200
View File
@@ -0,0 +1,200 @@
# Optional remote MongoDB replica
This deployment option converts the existing standalone MongoDB container into
a replica set named `rs0` and adds a remote, hidden, non-voting secondary.
Normal single-node deployments continue to use `docker-compose.yml` alone.
The remote member is designed as a recovery and backup source:
- It cannot become primary.
- Its availability does not affect production majority writes.
- It is hidden from application traffic.
- It is delayed by one hour by default, providing a short recovery window for
accidental deletes or a bad migration.
Replication is not a backup. Destructive changes eventually reach every
member. Continue making encrypted, off-host `mongodump` archives from the
secondary.
## Requirements
1. Take and verify a `mongodump` backup before converting production.
2. Install this repository on both servers.
3. Connect the servers through a trusted private network such as WireGuard,
Tailscale, a private VLAN, or a cloud private network.
4. Create stable DNS names that resolve from both servers and from the
application container, for example:
- `mongo-primary.internal`
- `mongo-secondary.internal`
5. Permit TCP port `27017` only between the required private-network hosts.
Do not expose MongoDB to the public internet.
6. Run the exact same MongoDB version on both servers. Find the current
production version with:
```bash
docker compose exec mongodb mongod --version
```
Set the matching image in both servers' `.env` files:
```env
MONGO_IMAGE=mongo:<exact-version>
```
## 1. Generate the shared member key
On the primary server:
```bash
./scripts/mongodb-replica.sh generate-key
```
This creates `secrets/mongo-replica-keyfile` without overwriting an existing
key. Copy that exact file securely to the same repository-relative path on the
secondary server:
```bash
scp secrets/mongo-replica-keyfile \
secondary-server:/path/to/anonymous_github/secrets/mongo-replica-keyfile
```
Keep this key outside source control and backups that are accessible to
untrusted users. Every replica-set member must share the same key.
## 2. Configure production
Add these values to the primary server's `.env`:
```env
# Use the exact version already running in production.
MONGO_IMAGE=mongo:<exact-version>
# The primary server's private/VPN interface.
MONGO_BIND_ADDRESS=<primary-private-ip>
MONGO_REPLICA_KEYFILE=./secrets/mongo-replica-keyfile
# Make the replica overlay the default for future Compose commands.
COMPOSE_FILE=docker-compose.yml:docker-compose.replica-primary.yml
# URL-encode special characters in the username and password.
MONGODB_URI=mongodb://<user>:<password>@mongo-primary.internal:27017/production?authSource=admin&replicaSet=rs0&retryWrites=true&w=majority
```
Stop application writers, leaving MongoDB available:
```bash
docker compose stop anonymous_github streamer
```
Start MongoDB with replica-set support and initialize the existing database as
the primary:
```bash
./scripts/mongodb-replica.sh primary-up \
mongo-primary.internal:27017
```
`primary-up` is idempotent. If `rs0` is already initialized, it reports that
state instead of replacing the replica-set configuration.
## 3. Start the remote secondary
On the secondary server, create a minimal `.env`:
```env
MONGO_IMAGE=mongo:<same-exact-version-as-primary>
MONGO_BIND_ADDRESS=<secondary-private-ip>
MONGO_REPLICA_KEYFILE=./secrets/mongo-replica-keyfile
```
Start its empty MongoDB data volume:
```bash
./scripts/mongodb-replica.sh secondary-up
```
Do not copy the primary's live WiredTiger volume. MongoDB performs the initial
sync after the member is added.
## 4. Add and verify the secondary
Back on the primary server, add the remote member with the default one-hour
delay:
```bash
./scripts/mongodb-replica.sh add-secondary \
mongo-secondary.internal:27017
```
To keep the remote copy current instead, explicitly set a zero-second delay:
```bash
./scripts/mongodb-replica.sh add-secondary \
mongo-secondary.internal:27017 0
```
Check initial-sync and replication state:
```bash
./scripts/mongodb-replica.sh status
```
Wait until the remote member reports `SECONDARY`, then restart the application:
```bash
docker compose up -d anonymous_github streamer redis
```
## Operations
Use the primary overlay for every future operation on the production stack.
Keeping `COMPOSE_FILE` in `.env` makes ordinary commands such as
`docker compose up -d` and `docker compose logs mongodb` use it automatically.
Check replica status:
```bash
./scripts/mongodb-replica.sh status
```
If a destructive production operation occurs and the secondary is delayed,
stop the secondary container immediately before the delay window elapses:
```bash
docker compose -f docker-compose.replica-secondary.yml stop mongodb-secondary
```
Then take a copy or logical dump of the delayed data before attempting
recovery.
For automatic failover, use three voting data-bearing members instead of
turning this two-server recovery topology into a two-voter replica set. Two
voters require both servers to acknowledge a majority and can make production
unwritable during a network outage.
## Troubleshooting
- All members must use the same replica-set name (`rs0`), MongoDB version, and
shared keyfile.
- The hostnames stored in `rs.conf()` must resolve from every member.
- The application container must also resolve `mongo-primary.internal`. Use
private DNS or a Compose `extra_hosts` entry if host DNS is not propagated
into Docker.
- Check container logs with:
```bash
docker compose logs mongodb
docker compose -f docker-compose.replica-secondary.yml logs mongodb-secondary
```
- Re-run `primary-up`, `add-secondary`, or `status` safely; the management
operations do not replace existing replica-set members.
## MongoDB references
- [Convert a standalone server to a replica set](https://www.mongodb.com/docs/manual/tutorial/convert-standalone-to-replica-set/)
- [Deploy a replica set with member authentication](https://www.mongodb.com/docs/v8.0/tutorial/deploy-replica-set-with-keyfile-access-control/)
- [Configure a delayed member](https://www.mongodb.com/docs/manual/tutorial/configure-a-delayed-replica-set-member/)
- [Hidden replica-set members](https://www.mongodb.com/docs/manual/core/replica-set-hidden-member/)
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source_keyfile="${MONGO_REPLICA_KEYFILE_PATH:-/run/secrets/mongo-replica-keyfile}"
runtime_keyfile="/tmp/mongo-replica-keyfile"
if [[ ! -s "$source_keyfile" ]]; then
echo "MongoDB replica keyfile is missing or empty: $source_keyfile" >&2
exit 1
fi
# Bind-mounted secrets commonly have host ownership or permissive modes that
# mongod rejects. Copy to an ephemeral location with the required ownership
# and permissions before the official image entrypoint drops privileges.
install -m 400 -o mongodb -g mongodb "$source_keyfile" "$runtime_keyfile"
exec /usr/local/bin/docker-entrypoint.sh "$@"
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
set -Eeuo pipefail
repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
primary_files=(
-f "$repository_root/docker-compose.yml"
-f "$repository_root/docker-compose.replica-primary.yml"
)
secondary_files=(
-f "$repository_root/docker-compose.replica-secondary.yml"
)
compose_prefix=(docker compose)
if [[ -n "${COMPOSE_ENV_FILE:-}" ]]; then
compose_prefix+=(--env-file "$COMPOSE_ENV_FILE")
fi
keyfile="${MONGO_REPLICA_KEYFILE:-$repository_root/secrets/mongo-replica-keyfile}"
if [[ "$keyfile" != /* ]]; then
keyfile="$repository_root/${keyfile#./}"
fi
usage() {
cat <<'USAGE'
Usage:
./scripts/mongodb-replica.sh generate-key
./scripts/mongodb-replica.sh primary-up <primary-hostname:port>
./scripts/mongodb-replica.sh secondary-up
./scripts/mongodb-replica.sh add-secondary <secondary-hostname:port> [delay-seconds]
./scripts/mongodb-replica.sh status
Environment:
COMPOSE_ENV_FILE Optional Compose environment file.
MONGO_REPLICA_KEYFILE Shared keyfile path (default: ./secrets/mongo-replica-keyfile).
The primary and secondary hostnames must resolve from both MongoDB servers.
The remote member is added hidden, priority 0, and non-voting so a WAN outage
cannot block production majority writes. Set delay-seconds to 0 for no delay.
USAGE
}
validate_host() {
if [[ ! "$1" =~ ^[A-Za-z0-9.-]+:[0-9]{1,5}$ ]]; then
echo "Expected a resolvable hostname and port, for example mongo-primary.internal:27017" >&2
exit 2
fi
}
validate_delay() {
if [[ ! "$1" =~ ^[0-9]+$ ]]; then
echo "Delay must be a non-negative number of seconds" >&2
exit 2
fi
}
require_keyfile() {
if [[ ! -s "$keyfile" ]]; then
echo "Replica keyfile not found: $keyfile" >&2
echo "Run ./scripts/mongodb-replica.sh generate-key first." >&2
exit 2
fi
}
primary_compose() {
"${compose_prefix[@]}" "${primary_files[@]}" "$@"
}
secondary_compose() {
"${compose_prefix[@]}" "${secondary_files[@]}" "$@"
}
mongo_eval() {
local javascript="$1"
primary_compose exec -T -e "MONGO_RS_EVAL=$javascript" mongodb sh -eu -c \
'mongosh --quiet \
--username="$MONGO_INITDB_ROOT_USERNAME" \
--password="$MONGO_INITDB_ROOT_PASSWORD" \
--authenticationDatabase=admin \
--eval "$MONGO_RS_EVAL"'
}
wait_for_primary_container() {
local attempt
for attempt in $(seq 1 60); do
if primary_compose exec -T mongodb sh -eu -c \
'mongosh --quiet \
--username="$MONGO_INITDB_ROOT_USERNAME" \
--password="$MONGO_INITDB_ROOT_PASSWORD" \
--authenticationDatabase=admin \
--eval "db.adminCommand({ ping: 1 }).ok"' >/dev/null 2>&1; then
return
fi
sleep 2
done
echo "MongoDB did not become ready within 120 seconds" >&2
exit 1
}
command="${1:-}"
case "$command" in
generate-key)
if [[ -e "$keyfile" ]]; then
echo "Refusing to overwrite existing keyfile: $keyfile" >&2
exit 2
fi
mkdir -p "$(dirname "$keyfile")"
umask 077
openssl rand -base64 756 > "$keyfile"
chmod 600 "$keyfile"
echo "Created $keyfile"
echo "Copy this exact file securely to the secondary server."
;;
primary-up)
primary_host="${2:-}"
validate_host "$primary_host"
require_keyfile
primary_compose up -d mongodb
wait_for_primary_container
mongo_eval "
try {
const status = rs.status();
print('Replica set already initialized as ' + status.set);
} catch (error) {
if (error.code !== 94 && error.codeName !== 'NotYetInitialized') throw error;
printjson(rs.initiate({
_id: 'rs0',
members: [{ _id: 0, host: '$primary_host', priority: 1, votes: 1 }]
}));
}
"
;;
secondary-up)
require_keyfile
secondary_compose up -d mongodb-secondary
;;
add-secondary)
secondary_host="${2:-}"
delay="${3:-3600}"
validate_host "$secondary_host"
validate_delay "$delay"
mongo_eval "
const host = '$secondary_host';
const existing = rs.conf().members.find((member) => member.host === host);
if (existing) {
print('Secondary already configured: ' + host);
} else {
printjson(rs.add({
host,
priority: 0,
votes: 0,
hidden: true,
secondaryDelaySecs: $delay
}));
}
"
;;
status)
mongo_eval "
printjson(rs.status().members.map((member) => ({
name: member.name,
state: member.stateStr,
health: member.health,
replicationTime: member.optimeDate
})));
"
;;
*)
usage
[[ -n "$command" ]] && exit 2
;;
esac
+10 -1
View File
@@ -26,6 +26,12 @@ interface Config {
DB_USERNAME: string; DB_USERNAME: string;
DB_PASSWORD: string; DB_PASSWORD: string;
DB_HOSTNAME: string; DB_HOSTNAME: string;
/**
* Complete MongoDB connection string. When set, this takes precedence over
* DB_USERNAME, DB_PASSWORD, and DB_HOSTNAME and supports replica-set seed
* lists and options.
*/
MONGODB_URI: string;
FOLDER: string; FOLDER: string;
additionalExtensions: string[]; additionalExtensions: string[];
S3_BUCKET: string | null; S3_BUCKET: string | null;
@@ -73,6 +79,7 @@ const config: Config = {
DB_USERNAME: "admin", DB_USERNAME: "admin",
DB_PASSWORD: "password", DB_PASSWORD: "password",
DB_HOSTNAME: "mongodb", DB_HOSTNAME: "mongodb",
MONGODB_URI: "",
REDIS_HOSTNAME: "redis", REDIS_HOSTNAME: "redis",
REDIS_PORT: 6379, REDIS_PORT: 6379,
FOLDER: resolve(__dirname, "..", "repositories"), FOLDER: resolve(__dirname, "..", "repositories"),
@@ -142,8 +149,10 @@ if (isProduction) {
const insecureDefaults: [string, string][] = [ const insecureDefaults: [string, string][] = [
["CLIENT_ID", "CLIENT_ID"], ["CLIENT_ID", "CLIENT_ID"],
["CLIENT_SECRET", "CLIENT_SECRET"], ["CLIENT_SECRET", "CLIENT_SECRET"],
["DB_PASSWORD", "password"],
]; ];
if (!config.MONGODB_URI) {
insecureDefaults.push(["DB_PASSWORD", "password"]);
}
for (const [key, badValue] of insecureDefaults) { for (const [key, badValue] of insecureDefaults) {
if ((config as unknown as Record<string, unknown>)[key] === badValue) { if ((config as unknown as Record<string, unknown>)[key] === badValue) {
throw new Error( throw new Error(
+10 -4
View File
@@ -8,7 +8,12 @@ import PullRequest from "../core/PullRequest";
import AnonymizedGistModel from "../core/model/anonymizedGists/anonymizedGists.model"; import AnonymizedGistModel from "../core/model/anonymizedGists/anonymizedGists.model";
import Gist from "../core/Gist"; import Gist from "../core/Gist";
const MONGO_URL = `mongodb://${config.DB_USERNAME}:${config.DB_PASSWORD}@${config.DB_HOSTNAME}:27017/`; export function getMongoUrl(): string {
return (
config.MONGODB_URI ||
`mongodb://${config.DB_USERNAME}:${config.DB_PASSWORD}@${config.DB_HOSTNAME}:27017/production`
);
}
export const database = mongoose.connection; export const database = mongoose.connection;
@@ -16,11 +21,12 @@ export let isConnected = false;
export async function connect() { export async function connect() {
mongoose.set("strictQuery", false); mongoose.set("strictQuery", false);
await mongoose.connect(MONGO_URL + "production", { const options: ConnectOptions = {
authSource: "admin",
appName: "Anonymous GitHub Server", appName: "Anonymous GitHub Server",
compressors: "zstd", compressors: "zstd",
} as ConnectOptions); };
if (!config.MONGODB_URI) options.authSource = "admin";
await mongoose.connect(getMongoUrl(), options);
isConnected = true; isConnected = true;
return database; return database;
+38
View File
@@ -0,0 +1,38 @@
require("ts-node/register/transpile-only");
const { expect } = require("chai");
const config = require("../src/config").default;
const { getMongoUrl } = require("../src/server/database");
describe("MongoDB connection configuration", function () {
const original = {
MONGODB_URI: config.MONGODB_URI,
DB_USERNAME: config.DB_USERNAME,
DB_PASSWORD: config.DB_PASSWORD,
DB_HOSTNAME: config.DB_HOSTNAME,
};
afterEach(function () {
Object.assign(config, original);
});
it("uses a complete replica-set URI when configured", function () {
const uri =
"mongodb://user:password@mongo-primary.internal:27017/production" +
"?authSource=admin&replicaSet=rs0&w=majority";
config.MONGODB_URI = uri;
expect(getMongoUrl()).to.equal(uri);
});
it("retains the existing single-node connection fallback", function () {
config.MONGODB_URI = "";
config.DB_USERNAME = "user";
config.DB_PASSWORD = "password";
config.DB_HOSTNAME = "mongodb";
expect(getMongoUrl()).to.equal(
"mongodb://user:password@mongodb:27017/production"
);
});
});