Skip to content
meetadev for AndroidYour feed and Pulse alerts, in your pocket.Get it on Google PlayGet app
Blogs
Deploying a self hosted financial grade MongoDB replica set · Part 5 of 11

The database that says no first: installing and hardening MongoDB on Azure

A self-hosted MongoDB 8.2 install across three Azure regions, configured to require TLS, enforce x.509 cluster authentication, and refuse every connection that doesn't present a valid client certificate — including the operator's. What the install actually takes, and the half-dozen operational details the documentation doesn't connect for you.

27 May · 23 min readPublished 27 May 2026 · 23 min read · 150 views
Updated 18 Oct
The database that says no first: installing and hardening MongoDB on Azure

The setup#

Three Ubuntu 22.04 VMs, one in UAE North, one in South Africa North, one in West Europe. Private DNS resolves each by hostname across a VNet mesh. NSGs deny everything except MongoDB traffic between the three subnets and SSH from the operator's /32. A two-tier PKI has issued four certificates — three for the cluster members, one for the operator — signed by an intermediate CA whose root sits in Azure Key Vault and never moves.

Each VM's hostname matches its certificate's primary SAN:

Python
$ hostname --fqdn
vm-mongodb-uae   # Azure VM name; the mongod identity is node-uae.mongo.internal

What this infrastructure does not yet have is a database. No mongod process exists. Port 27017 is not listening anywhere. The TLS certificates sit in /etc/mongodb/tls/ on each node, encrypted at rest, waiting for the only process on the cluster that has any reason to read them.

This part installs that process.

svg.png
svg.png

Storage before installation#

Disk layout is the choice made before installing the database, not after. MongoDB stores data at /var/lib/mongodb — and on a default Azure VM, that path is on the same disk as the operating system, a 30 GiB OS disk shared with everything else the VM does. Conflating OS lifecycle with data lifecycle is a common operational regret, and it is avoidable in the time it takes to type one Azure CLI command per node.

Three Premium SSD P6 disks, 64 GiB each, one per VM. P6 is the smallest Premium tier — 240 IOPS, 50 MB/s throughput — and the choice is calibrated. A production financial workload would size at P10 (500 IOPS) or higher; P30 is more typical for actual transaction throughput. The series sits well below that bar by design. P6 gives this build enough headroom that the disk never becomes the bottleneck, while keeping the monthly cost honest for personal infrastructure. The production upgrade path is direct: resize the disks, no other architectural change required.

Provisioning runs in two az calls per VM, one to create the disk and one to attach it at LUN 0:

SQL
az disk create \
  --resource-group rg-mongodb-uae-north \
  --name disk-mongodb-data-uae \
  --location uaenorth \
  --size-gb 64 \
  --sku Premium_LRS

az vm disk attach \
  --resource-group rg-mongodb-uae-north \
  --vm-name vm-mongodb-uae \
  --name disk-mongodb-data-uae \
  --lun 0

Disks come back encrypted at rest with Microsoft-managed keys — Azure does this automatically for Premium SSD, no flag required. That is not customer-managed encryption — that would mean Key Vault keys and is a financial-grade upgrade — but it is also not nothing. Anyone who walks off with one of these disks gets ciphertext.

After attach, the disk appears as /dev/sdc on Linux. lsblk confirms before any destructive operation runs:

Bash
$ lsblk
NAME    MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
sda       8:0    0   30G  0 disk
├─sda1    8:1    0 29.9G  0 part /
├─sda14   8:14   0    4M  0 part
└─sda15   8:15   0  106M  0 part /boot/efi
sdb       8:16   0   16G  0 disk
└─sdb1    8:17   0   16G  0 part /mnt
sdc       8:32   0   64G  0 disk

sda is the OS disk, sdb is the Azure ephemeral disk (lost on stop/deallocate), sdc is the data disk just attached. sdc has no partition and no mountpoint — confirming it is the target.

GPT partition table, single xfs partition (MongoDB's preferred filesystem on Linux because of its allocation patterns), mount at /var/lib/mongodb. The fstab entry uses the partition's UUID rather than the device name — device names can shuffle across reboots, UUIDs cannot:

Bash
sudo parted /dev/sdc --script mklabel gpt mkpart primary xfs 0% 100%
sudo mkfs.xfs /dev/sdc1
sudo mkdir -p /var/lib/mongodb
UUID=$(sudo blkid -s UUID -o value /dev/sdc1)
echo "UUID=$UUID  /var/lib/mongodb  xfs  defaults,noatime  0  2" | sudo tee -a /etc/fstab
sudo mount -a
df -h /var/lib/mongodb

A mount-cycle test (sudo umount /var/lib/mongodb && sudo mount -a && df -h) confirms fstab before mongod is ever installed.

Bash
UAE   /dev/sdc1  →  /var/lib/mongodb  (64 GiB, xfs, Premium SSD P6)
SA    /dev/sdc1  →  /var/lib/mongodb  (64 GiB, xfs, Premium SSD P6)
WEU   /dev/sdc1  →  /var/lib/mongodb  (64 GiB, xfs, Premium SSD P6)

That is the substrate. mongod is not installed yet.


Linux defaults that break a database#

MongoDB has documented opinions about the Linux it runs on. Two of those opinions cause real production pathologies if ignored.

Transparent Huge Pages. Linux's default is to merge small memory pages into 2 MiB huge pages in the background, via a kernel thread. Memory-mapped database workloads — which is what MongoDB's WiredTiger engine produces — see this background merging as latency spikes. MongoDB's official guidance is to disable THP entirely. The standard fix is to write never into two /sys files at boot. A crontab @reboot entry is fragile. A GRUB kernel parameter is invisible in audit. A systemd oneshot unit ordered Before=mongod.service is explicit, visible to systemctl list-units, and runs before mongod's first read of the THP setting:

Bash
sudo tee /etc/systemd/system/disable-thp.service > /dev/null <<'EOF'
[Unit]
Description=Disable Transparent Huge Pages for MongoDB
DefaultDependencies=no
After=sysinit.target local-fs.target
Before=mongod.service

[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo never > /sys/kernel/mm/transparent_hugepage/enabled'
ExecStart=/bin/sh -c 'echo never > /sys/kernel/mm/transparent_hugepage/defrag'
RemainAfterExit=true

[Install]
WantedBy=basic.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now disable-thp.service

Swap. The common advice for databases is "disable swap entirely." This is the wrong setting for MongoDB. Disabling swap means the Linux OOM killer fires immediately under any memory pressure and frequently picks mongod as its target. MongoDB's actual guidance is to keep swap enabled at its provisioned size, set vm.swappiness=1 so the kernel only swaps under genuine pressure, and accept swap as a buffer that protects mongod from being killed during transient spikes. Swappiness persists in /etc/sysctl.d/99-mongodb.conf so it survives reboots:

Bash
echo "vm.swappiness=1" | sudo tee /etc/sysctl.d/99-mongodb.conf
sudo sysctl --system

Together, these two settings — THP off, swappiness=1 — separate a mongod host that performs predictably from one that does not.

Bash
$ cat /sys/kernel/mm/transparent_hugepage/enabled
always madvise [never]

$ cat /proc/sys/vm/swappiness
1

SSH hardening, in the right order#

SSH hardening on each VM is part of the same pass. Two pieces: tightening the sshd configuration itself, and tightening the NSG aperture.

The sshd part is a single drop-in file at /etc/ssh/sshd_config.d/40-hardening.conf with ten explicit settings — PasswordAuthentication no, PermitRootLogin no, MaxAuthTries 3, idle-disconnect timers, AllowUsers azureuser to make sure no other account on the system can SSH at all. Most are Ubuntu defaults; they are set explicitly so the config is self-documenting and survives image changes:

Bash
sudo tee /etc/ssh/sshd_config.d/40-hardening.conf > /dev/null <<'EOF'
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers azureuser
EOF

sudo sshd -t && sudo systemctl reload ssh

The filename matters more than it should. sshd processes drop-in files alphabetically, and within the merged config the first occurrence of a directive wins — unusual for Unix configs, which typically use "last wins." The Azure-tuned Ubuntu image ships defaults in 50-cloud-init.conf and 50-cloudimg-settings.conf. A drop-in named 99-hardening.conf loses every directive it tries to override, because by the time sshd reaches it, every value has already been set. Naming the hardening file 40-hardening.conf ensures it loads before the cloud-image defaults. The behaviour is real and non-obvious — sshd directive resolution depends on filename ordering, and the cloud-image defaults set values that quietly override a hardening file that loads later.

The NSG aperture tightens last — from the /24 set in Part 2 (to accommodate dynamic ISP-assigned IPs during setup) down to the operator's current /32:

SQL
az network nsg rule update \
  --resource-group rg-mongodb-uae-north \
  --nsg-name nsg-mongodb-uae \
  --name allow-ssh \
  --source-address-prefixes 102.213.48.206/32

The order is deliberate: host-level hardening first, network-level hardening last. A sshd_config typo locks the operator out of new SSH sessions, but the existing session can still recover the host before the network aperture shrinks. An NSG rule typo locks new connections out — Azure Cloud Shell remains as the browser-based escape hatch. Host-side problems are recoverable from inside; network-side problems are recoverable from outside. Both are recoverable when the order is right.

3 nsg updated.png
3 nsg updated.png

Installing MongoDB 8.2, and the GPG-key convention nobody documents#

The MongoDB signing-key URL convention is one key per major line, not per minor release. server-8.0.asc signs every minor release in the 8.x family — 8.0, 8.1, 8.2, 8.3, and every patch within them. The server-8.2.asc URL returns 404. So does server-8.3.asc.

Importing the key from a version-specific URL fails — curl returns 404, gpg writes nothing, and apt refuses the 8.2 repo because nothing valid signs it:

Bash
W: GPG error: https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.2 InRelease:
   The following signatures couldn't be verified because the public key
   is not available: NO_PUBKEY 41DE058A4E7DCA05

The working configuration uses the 8.0 key URL with the 8.2 repo path:

Bash
curl -fsSL https://pgp.mongodb.com/server-8.0.asc \
  | sudo gpg --dearmor -o /usr/share/keyrings/mongodb-server-8.0.gpg

echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] \
https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.2 multiverse" \
  | sudo tee /etc/apt/sources.list.d/mongodb-org-8.2.list

sudo apt-get update

The signed-by= clause references the 8.0 key; the repo path is mongodb-org/8.2. The same signing infrastructure covers both. apt accepts the 8.2 InRelease file because the signature on it validates with the 8.0 key.

Available 8.2.x patches are visible with apt-cache madison:

Bash
$ apt-cache madison mongodb-org | head -5
mongodb-org |      8.2.9 | .../mongodb-org/8.2/multiverse amd64 Packages
mongodb-org |      8.2.7 | .../mongodb-org/8.2/multiverse amd64 Packages
mongodb-org |      8.2.6 | .../mongodb-org/8.2/multiverse amd64 Packages

The install itself is one apt command with every component pinned to 8.2.9:

Bash
sudo apt-get install -y \
  mongodb-org=8.2.9 \
  mongodb-org-database=8.2.9 \
  mongodb-org-server=8.2.9 \
  mongodb-mongosh-shared-openssl3=2.8.3 \
  mongodb-org-shell=8.2.9 \
  mongodb-org-mongos=8.2.9 \
  mongodb-org-tools=8.2.9

Two things in that command worth flagging. The mongosh package versions independently of the server — current mongosh is 2.8.3, the server is 8.2.9; trying to pin both to the same number fails. The openssl3 variant is also explicit, because mongodb-mongosh is a virtual package that resolves to either the openssl11 or openssl3 build depending on the host. Ubuntu 22.04 ships OpenSSL 3, so the openssl3 variant is the right pick.

Verify the install:

JavaScript
$ mongod --version
db version v8.2.9
Build Info: {
    "version": "8.2.9",
    "openSSLVersion": "OpenSSL 3.0.2 15 Mar 2022",
    "modules": [],
    "allocator": "tcmalloc-google",
    "environment": { "distmod": "ubuntu2204", "distarch": "x86_64" }
}

The mongodb-org-server package also creates a mongodb system user (UID 114) and group (GID 123). mongod runs as that user; every file path it touches must be readable (and where appropriate, writable) by it.

After the install, every MongoDB package is held against accidental upgrade:

Bash
sudo apt-mark hold \
  mongodb-org mongodb-org-database mongodb-org-server \
  mongodb-mongosh-shared-openssl3 mongodb-org-shell \
  mongodb-org-mongos mongodb-org-tools \
  mongodb-database-tools mongodb-org-database-tools-extra

apt-mark showhold | wc -l    # 9

apt-mark hold tells apt to refuse to upgrade these packages even when apt upgrade is run routinely. Without it, a future apt upgrade would silently roll MongoDB to whatever is latest, breaking the version pin and potentially desynchronising the three nodes (whichever node runs apt upgrade first gets a different patch). The hold is the human-action gate against version drift.

Eight decisions in one config file#

The mongod.conf for this cluster is the operational expression of eight decisions locked in the design phase. The full file, written to /etc/mongod.conf on each VM (the path the package install expects, the path the systemd unit references):

YAML
storage:
  dbPath: /var/lib/mongodb
  journal:
    enabled: true

systemLog:
  destination: file
  logAppend: true
  path: /var/log/mongodb/mongod.log

processManagement:
  fork: false
  pidFilePath: /var/run/mongodb/mongod.pid
  timeZoneInfo: /usr/share/zoneinfo

net:
  port: 27017
  bindIp: 0.0.0.0
  tls:
    mode: requireTLS
    certificateKeyFile: /etc/mongodb/tls/node-uae.combined.pem
    certificateKeyFilePassword:
      __exec: 'cat /etc/mongodb/tls/private/uae.passphrase'
      type: 'string'
      trim: 'whitespace'
    CAFile: /etc/mongodb/tls/ca-chain.cert.pem
    allowConnectionsWithoutCertificates: false
    disabledProtocols: TLS1_0,TLS1_1

security:
  authorization: enabled
  clusterAuthMode: x509

replication:
  replSetName: rs0

The eight decisions, mapped to the file:

  • storage.dbPath: /var/lib/mongodb — data lives on the dedicated 64 GiB Premium SSD P6 disk mounted at this path. Separation of OS lifecycle from data lifecycle.

  • storage.journal.enabled: true — write-ahead journaling on. The default and the correct value; explicit here so the file documents itself.

  • processManagement.fork: false — mongod runs in the foreground. systemd manages the process lifecycle; the legacy fork: true pattern conflicts with that.

  • net.tls.mode: requireTLS — every connection must use TLS. No plaintext, no STARTTLS fallback.

  • net.tls.allowConnectionsWithoutCertificates: false — no SCRAM-without-cert escape hatch. Strict cert-only.

  • net.tls.disabledProtocols: TLS1_0,TLS1_1 — only TLS 1.2 and 1.3 accepted. Set explicitly because some package builds still default to allowing the older versions.

  • security.authorization: enabled — RBAC on. Every operation needs a permitted user.

  • security.clusterAuthMode: x509 — peers authenticate to each other via certificates, not a shared keyfile. Per-node identity.

replication.replSetName: rs0 is configuration, not a security decision — but it has an operational implication. mongod starts in a pre-initiated replica set state and will not accept writes until rs.initiate() runs in the next part of this build.

TLS material lives at the paths from the PKI build:

  • certificateKeyFile: /etc/mongodb/tls/node-<region>.combined.pem — cert and encrypted private key concatenated into one PEM

  • CAFile: /etc/mongodb/tls/ca-chain.cert.pem — root + intermediate, the trust chain

The combined PEM is the format mongod expects — leaf cert followed by encrypted private key, concatenated. The PKI build leaves them as separate files; the install combines them:

Bash
sudo bash -c 'cat /etc/mongodb/tls/node-uae.cert.pem \
  /etc/mongodb/tls/private/node-uae.key.pem \
  > /etc/mongodb/tls/node-uae.combined.pem'

sudo chown mongodb:mongodb /etc/mongodb/tls/node-uae.combined.pem
sudo chmod 600 /etc/mongodb/tls/node-uae.combined.pem

sudo grep "BEGIN " /etc/mongodb/tls/node-uae.combined.pem
# -----BEGIN CERTIFICATE-----
# -----BEGIN ENCRYPTED PRIVATE KEY-----

The encrypted private key has a passphrase. mongod has to get that passphrase from somewhere at startup, and this is where the install hits three real obstacles in sequence.


Encrypted keys, and how mongod actually reads passphrases#

The design called for the passphrase to live in a separate file at /etc/mongodb/tls/private/<region>.passphrase, owned by mongodb, mode 0400, referenced from mongod.conf via tls.certificateKeyFilePassword. The pattern is what the configuration option appears to support. It is not.

Creating the file looks routine:

Bash
sudo touch /etc/mongodb/tls/private/uae.passphrase
sudo chown mongodb:mongodb /etc/mongodb/tls/private/uae.passphrase
sudo chmod 0400 /etc/mongodb/tls/private/uae.passphrase

Writing to it surfaces the first issue. tee invoked with Enter writes the passphrase content plus a trailing newline. OpenSSL's command-line tools (openssl pkey -passin file:) strip trailing whitespace before passing the passphrase to the decryption routine, so a verification command like the following succeeds silently — which makes the passphrase file appear correct under manual verification:

TypeScript
sudo openssl pkey -in /etc/mongodb/tls/private/node-uae.key.pem \
  -pubout -noout \
  -passin file:/etc/mongodb/tls/private/uae.passphrase

mongod does no such stripping. It uses the raw file contents as the literal passphrase, newline and all. The result is bad decrypt at startup, with no signal that the manual verification with openssl and the actual verification mongod performs are doing different things:

Bash
Cannot read PEM key
error:1C800064:Provider routines::bad decrypt

xxd on the file confirms the silent newline:

Bash
$ sudo xxd /etc/mongodb/tls/private/uae.passphrase | tail -1
00000020: 6570 210a                                ep!.

The trailing 0a is the newline tee added. One fix is to write the file with printf "%s" instead of tee, so the file ends in the last character of the passphrase and nothing else:

Bash
sudo bash -c 'printf "%s" "<passphrase-here>" > /etc/mongodb/tls/private/uae.passphrase'
sudo chown mongodb:mongodb /etc/mongodb/tls/private/uae.passphrase
sudo chmod 0400 /etc/mongodb/tls/private/uae.passphrase

sudo xxd /etc/mongodb/tls/private/uae.passphrase | tail -1
# 00000020: 6570 21                                  ep!     ← no trailing 0a

The cleaner fix uses mongod's config expansion feature, which solves the next problem too.

The second issue is mongod's interpretation of the config value. certificateKeyFilePassword takes a literal passphrase string — there is no built-in "read from file" semantic; the value is taken as-is. Pointing the option at a file path means mongod tries to decrypt the key with the literal string /etc/mongodb/tls/private/uae.passphrase as the passphrase. Which fails, predictably.

The third issue is the proper workaround. MongoDB has a config-expansion feature called __exec that runs a shell command at startup and substitutes the output into the config. It is the supported way to keep secrets out of mongod.conf. The YAML form is:

Python
certificateKeyFilePassword:
  __exec: 'cat /etc/mongodb/tls/private/uae.passphrase'
  type: 'string'
  trim: 'whitespace'

The trim: 'whitespace' clause handles the trailing-newline problem as a side effect. But __exec is gated behind three preconditions:

  1. mongod must be started with --configExpand=exec. Without the flag, the expansion is not parsed; mongod terminates with net.tls.certificateKeyFilePassword.__exec support has not been enabled. The flag goes onto the systemd ExecStart line via a drop-in (covered below).

  2. The mongod.conf file must be exclusively writable by the user running mongod. The default apt install ships /etc/mongod.conf as root:root. mongod refuses to use __exec until the config file is chown mongodb:mongodb and chmod 600:

Bash
sudo chown mongodb:mongodb /etc/mongod.conf
   sudo chmod 600 /etc/mongod.conf

The error message is explicit when this is not done:

SQL
When using --configExpand=exec, config file must be exclusively
   writable by current process user. File is not owned by current user
  1. The expansion block schema is strict. The configuration reference lists digest and digest_key as optional integrity fields. They are. But including them as empty strings rather than omitting them causes mongod to reject the config with Unsupported SHA256Block hash length: 0. The correct form is to omit those keys entirely.

With all three preconditions in place, mongod starts. The startup log shows the expansion:

JSON
"msg":"Processing config expansion",
"attr":{"expansion":"__exec","node":"net.tls.certificateKeyFilePassword"}

mongod runs cat /etc/mongodb/tls/private/uae.passphrase, takes the stdout, trims whitespace, uses the result as the passphrase to decrypt the encrypted private key in the combined PEM. The passphrase never lives in mongod.conf. The config file can be safely backed up, version-controlled, or shown in an article. The secret stays in a separate file, owned by the same user, mode 0400.

A permission mongod silently needs#

With expansion configured correctly, mongod still refuses to start on the first VM. The log shows the same bad decrypt error, but the root cause is different.

mongod runs as the mongodb system user. The passphrase file is at /etc/mongodb/tls/private/uae.passphrase, mode 0400, owned by mongodb. Permission to read the file is correct. The problem is one level up.

The private/ directory was created during the PKI build with mode 700 owned by azureuser. That is a defensible permission set in isolation — no one but the owner can list or traverse the directory. But mongod is not azureuser. When mongod attempts to open /etc/mongodb/tls/private/uae.passphrase, the kernel checks each component of the path. The private/ directory grants execute (traverse) permission only to its owner, and mongod is not the owner. mongod cannot reach the file at all — not because of the file's permissions, but because of the directory's.

The condition is reproducible:

Bash
$ sudo -u mongodb cat /etc/mongodb/tls/private/uae.passphrase > /dev/null
cat: /etc/mongodb/tls/private/uae.passphrase: Permission denied

The Unix rule: to read a file at /a/b/c, the process needs execute permission on every directory from the root down to b. A correctly-permissioned file inside an inaccessible directory is unreachable by anyone who lacks traverse on the directory.

The fix gives the mongodb group read+execute on private/:

Bash
sudo chgrp mongodb /etc/mongodb/tls/private
sudo chmod 750 /etc/mongodb/tls/private

sudo -u mongodb cat /etc/mongodb/tls/private/uae.passphrase > /dev/null \
  && echo "mongodb can read it"
# mongodb can read it

Owner (azureuser) retains full access, group (mongodb) gets read+traverse, others get nothing. mongod can now cd into private/ and read the passphrase file. Other users on the system still cannot see what is in the directory at all.

The same fix applies to the data directory and the log directory, both of which are created during disk mount and package install with ownership that mongod does not have by default:

Bash
sudo chown -R mongodb:mongodb /var/lib/mongodb
sudo chmod 755 /var/lib/mongodb
sudo chown -R mongodb:mongodb /var/log/mongodb

The mistake is easy to make once. The fix is a thirty-second change. Diagnosing it without knowing what to look for takes considerably longer — the file mode looks correct in isolation, the verification command runs successfully when typed as azureuser (who owns the directory), and mongod fails for a reason the error log does not name.


Process-level hardening#

The default mongod.service unit shipped by the package is functional. Additional process-level hardening goes into a systemd drop-in at /etc/systemd/system/mongod.service.d/hardening.conf. The drop-in does two things at once: enable the __exec expansion flag, and tighten the process sandbox:

Bash
sudo mkdir -p /etc/systemd/system/mongod.service.d

sudo tee /etc/systemd/system/mongod.service.d/hardening.conf > /dev/null <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/bin/mongod --config /etc/mongod.conf --configExpand=exec

ProtectSystem=strict
ReadWritePaths=/var/lib/mongodb /var/log/mongodb
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
EOF

sudo systemctl daemon-reload

Each directive:

  • ExecStart= (empty) followed by the real ExecStart= — the empty line clears the package-provided ExecStart, and the next line replaces it with one that includes the --configExpand=exec flag. This is the standard systemd pattern for overriding the entry point of a packaged service.

  • ProtectSystem=strict — the entire filesystem becomes read-only to mongod, except for the paths explicitly allowed. ReadWritePaths=/var/lib/mongodb /var/log/mongodb is the explicit allowlist. mongod cannot write to /etc, /usr, /tmp, /home, or anywhere else. If mongod is compromised and the attacker tries to write a payload to /etc/cron.d/, the kernel refuses.

  • ProtectHome=true — mongod cannot read /home. A database process has no legitimate reason to read user home directories.

  • PrivateTmp=true — mongod gets a private /tmp namespace. Other processes' temp files are invisible to it; its own temp files are invisible to them.

  • NoNewPrivileges=true — blocks privilege escalation via setuid binaries. Even if mongod somehow invoked a setuid binary, the kernel refuses to grant the setuid privilege.

systemctl cat mongod.service confirms the drop-in is being layered onto the package unit:

Bash
# /lib/systemd/system/mongod.service
[Unit]
Description=MongoDB Database Server
...
ExecStart=/usr/bin/mongod --config /etc/mongod.conf
...

# /etc/systemd/system/mongod.service.d/hardening.conf
[Service]
ExecStart=
ExecStart=/usr/bin/mongod --config /etc/mongod.conf --configExpand=exec
ProtectSystem=strict
ReadWritePaths=/var/lib/mongodb /var/log/mongodb
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true

None of these directives interfere with mongod functionality. All of them reduce the blast radius if mongod is ever compromised. The cost is six lines in a drop-in file.


Active (running) is not yet operational#

With ownership fixed, expansion enabled, and the sandbox in place, mongod starts:

Bash
sudo systemctl enable mongod
sudo systemctl start mongod
sudo systemctl is-enabled mongod   # enabled
sudo systemctl status mongod --no-pager

systemctl status mongod reports active (running). The process is real, the PID is real, the memory footprint is around 100 MB.

mongo running.png
mongo running.png

Port 27017 is listening on all interfaces, in mongod's process namespace:

Bash
$ sudo ss -tlnp | grep 27017
LISTEN 0      4096   0.0.0.0:27017   0.0.0.0:*   users:(("mongod",pid=38850,fd=9))

That state is not "the cluster is operational." mongod is in a specific, intentional condition: a pre-initiated replica set member. It knows it belongs to rs0 (from the replication.replSetName config), but rs.initiate() has never run. The oplog does not exist. There is no primary. No connection can write data.

The log reflects this. Every second, mongod emits warnings about things that will be true once the replica set is operational but are not yet:

Bash
"msg":"Aggregate command executor error",
"attr":{"error":{"code":26,"codeName":"NamespaceNotFound",
"errmsg":"Collection [local.oplog.rs] not found."}}

"msg":"Failed to refresh key cache",
"attr":{"error":"ReadConcernMajorityNotAvailableYet: 
Read concern majority reads are currently not possible."}

"msg":"Failed to refresh query analysis configurations",
"attr":{"error":"PrimarySteppedDown: No primary exists currently"}

These are not errors in the deployment sense. They describe mongod's correct view of its own state: it is a replica set member, the replica set has not been initiated, so the oplog does not exist, majority reads are not possible, and no primary has been elected. Each warning stops the moment rs.initiate() runs in the next part of this build.

The same install sequence runs on the other two nodes. Three mongods on three continents, each active (running) on port 27017 with TLS required and x.509 cluster authentication enforced.


Verifying the auth layer end-to-end#

Connecting to mongod from a client verifies that TLS, the cert chain, the DN extraction, and the user lookup all function together. The NSG correctly refuses port 27017 from anywhere except the peer subnets — including the operator's laptop — so the smoke test runs from inside one of the VMs. The operator's combined PEM (admin client cert + encrypted key) is built and copied in temporarily:

Bash
# On the operator's laptop:
cat ~/meetadev-mongo-pki/client/omollo.cert.pem \
    ~/meetadev-mongo-pki/client/omollo.key.pem \
    > ~/meetadev-mongo-pki/client/omollo.combined.pem
chmod 600 ~/meetadev-mongo-pki/client/omollo.combined.pem

scp -i ~/.ssh/id_rsa \
    ~/meetadev-mongo-pki/client/omollo.combined.pem \
    azureuser@<sa-vm-ip>:/tmp/omollo.combined.pem

mongosh is installed on the VM via the same apt repository as the server, but only the mongodb-mongosh-shared-openssl3 package:

Bash
sudo apt-get install -y mongodb-mongosh-shared-openssl3=2.8.3

The smoke-test connection presents the admin client cert and requests x.509 authentication:

Bash
mongosh "mongodb://localhost:27017/?tls=true&tlsAllowInvalidHostnames=true&authMechanism=MONGODB-X509&authSource=%24external" \
  --tlsCAFile /etc/mongodb/tls/ca-chain.cert.pem \
  --tlsCertificateKeyFile /tmp/omollo.combined.pem \
  --eval "db.runCommand({ping:1})"

tlsAllowInvalidHostnames=true is the localhost-test concession — the server's cert SAN names node-southafrica.mongo.internal, not localhost. In real client connections from Part 5 onward, the FQDN is used and that flag is dropped.

mongosh prompts for the client key's passphrase. The TLS handshake completes. mongod responds:

Bash
MongoServerError: Could not find user 
"DC=mongo,DC=internal,CN=omollo,OU=cluster-clients,O=Meetadev" 
for db "$external"
mongo server error.png
mongo server error.png

What this error confirms:

  1. The TLS handshake completed. mongod accepted the client cert chain; the client accepted mongod's cert chain. Mutual TLS works.

  2. mongod extracted the Subject DN from the client certificate: DC=mongo,DC=internal,CN=omollo,OU=cluster-clients,O=Meetadev. The DN is in RFC 2253 canonical form, ordered leaf to root.

  3. mongod looked for that DN as a username in the $external database — the standard MongoDB convention for x.509-authenticated users.

  4. mongod did not find one. The connection was refused.

After the test, the client PEM is removed from the VM:

Bash
sudo shred -u /tmp/omollo.combined.pem

Every layer below the user-lookup — TCP reachability, TLS, cert chain validation, DN extraction, the $external lookup — worked. The reason the operator cannot yet query the cluster is that no MongoDB user record exists for that operator. Creating that user is the work of the next part of this build: it requires the localhost exception, MongoDB's narrow rule that allows the first user to be created over a localhost connection without authentication, before any other user exists. After the exception is used and the user record is in place, the exception closes, and the cluster never again accepts an unauthenticated connection.

The order is: build the cluster, the cluster refuses to authenticate the operator, the operator uses the localhost exception to create the user record, the cluster authenticates the operator from then on. In environments where deployment is run by automation against a clean install, the same sequence applies. Every connection from this point presents a cert chain; every cert chain presents a DN; every DN is matched against $external.


What this layer is not#

The same pattern as Part 3: name the gaps. Each one is a known, acceptable distance from a financial-grade deployment.

  • Premium SSD P6 (64 GiB) per node instead of P10 or P15. Calibrated for series workload, not for production data growth.

  • Public SSH endpoint with /32 firewall instead of Bastion-only or jump-host access. The /32 shrinks the public aperture to one address, which is appropriate for personal infrastructure; production deployments use Bastion (~$140/month for Standard) or VPN-only access.

  • Passphrase stored in a file on disk rather than pulled from Key Vault at boot via managed identity. The file is owned and read-only by the mongod user, but it is still local on the database host. A production deployment would use a systemd helper that fetches the secret from a vault at boot, leaving nothing on disk.

  • MongoDB apt repo consumed directly rather than mirrored internally for change control. Production deployments mirror to Artifactory or Nexus to control update timing.

  • No cgroup limits, AppArmor profile, or SELinux profile beyond the systemd hardening. Appropriate for dedicated-VM mongod where mongod is the only meaningful workload; would be added on multi-tenant hosts.

  • No tls.FIPSMode because Ubuntu's default OpenSSL is not FIPS-validated. Adding FIPS mode without the validated library underneath is theatre.

  • No explicit cipher-suite pinning — TLS 1.2/1.3 default suites instead. Production deployments in regulated industries pin specific suites; this build uses what mongod's link-time OpenSSL provides.

A regulated production deployment would close all of these. None of them require redesigning the work already done — they extend it. The staged upgrade path is plausible: move the disks to P10 first, add Bastion second, switch to vault-fetched passphrases third, mirror the apt repo fourth.


What's next#

The next part initiates the replica set. rs.initiate() runs against one of the nodes; the three nodes find each other over their x.509 certs and the private DNS from the network foundation; an election picks a primary. Right after that, the localhost exception is used to create the operator user, and the cluster begins authenticating the connections it was designed to authenticate.


Four down. One cluster left to build. 🎯

mongod is alive on three continents. 🌍 TLS required, x.509 enforced, no shared secrets, no second chances. The substrate became a database. 🔐

And the punchline: the database said no first. To me. By name. "Could not find user CN=omollo, OU=cluster-clients..." 🚫 That refusal is the most honest summary of what got built today.

The trust chain it's enforcing was built in the previous piece: Root in the vault, intermediate on the wire. 📖

⏭️ Part 5 is next: rs.initiate() runs, the three nodes find each other, an election picks a primary, the cluster becomes a cluster. The operator user gets created via the localhost exception — and the database starts answering "yes" to the connections it should. 🗳️

Drop a comment with your own MongoDB hardening choices. ⚡ Follow on Meetadev. Connect on LinkedIn. 👋

Osego CodesBronze tierWhat's up 2026

2 comments