Multi-Client Deploy Architecture
Deploying to a dozen isolated service accounts on one shared host — per-user nvm, per-user PM2, least-privilege sudo, no root anywhere.
Part 5 of 7
The hosting model
One staging host runs API servers for many clients. Each client is isolated by Linux user account:
Each service user owns its code, its own ~/.nvm, and its own PM2 daemon. Nothing is shared. A client on Node 20 and a client on Node 24 coexist without conflict, and a runaway process affects one account rather than the host.
Why not deploy as root
| Approach | Problem |
|---|---|
Jenkins SSHes as root | CI compromise = full host compromise. Files end up root-owned, so the service user can no longer write to its own directories at runtime. |
| Jenkins SSHes directly as each service user | Requires distributing a private key per client and managing a dozen key pairs. Key rotation becomes a project. |
SSH as one CI account, sudo -u into the service user | One key to manage. Access is granted per service user via sudoers.d and is individually auditable and revocable. |
The deploy chain
Setup — per client
One file per service user. The syntax ubuntu ALL=(kfc) NOPASSWD: ALL means: user ubuntu, from any host, may run any command as the user kfc, without a password. It grants nothing as root.
echo "ubuntu ALL=(kfc) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/ubuntu-to-kfc
sudo chmod 440 /etc/sudoers.d/ubuntu-to-kfc
# ALWAYS validate before closing the session
sudo visudo -c
visudo -c prints parsed OK for every file. A malformed sudoers file can lock every user out of sudo on the host, and recovery means single-user mode or a console session.# -n = non-interactive: fail immediately rather than prompting
sudo -n -u kfc whoami
# kfc
Using -n makes a misconfiguration show up as an immediate error rather than a hanging password prompt — important when the same command will later run unattended from Jenkins.
sudo -u kfc bash -c '
source /home/kfc/.nvm/nvm.sh
node -v
npm -v
pm2 list
'
source nvm.sh is mandatory and easy to forget.
nvm is a shell function, not a binary. It is installed by an .bashrc snippet — which a non-interactive shell never reads. Without the explicit source, node is simply not found, or worse, a different system-wide Node silently gets used. Every remote command block in the deploy must source nvm first.
# On the Jenkins controller, as the jenkins user
sudo -u jenkins ssh-keygen -t ed25519 -C "jenkins-deploy-stg" \
-f /var/lib/jenkins/.ssh/stg_deploy_key -N ""
sudo cat /var/lib/jenkins/.ssh/stg_deploy_key.pub
# On the staging host, as ubuntu
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "<paste public key>" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
chmod 755 ~ # home must NOT be group-writable
chown -R ubuntu:ubuntu ~/.ssh
sshd silently refuses key authentication if the home directory, .ssh, or authorized_keys have permissions that are too open. The client just sees Permission denied (publickey) with no explanation.
sudo -u jenkins ssh -i /var/lib/jenkins/.ssh/stg_deploy_key \
-o StrictHostKeyChecking=no ubuntu@10.10.1.223 \
'sudo -u kfc bash -c "source /home/kfc/.nvm/nvm.sh && node -v && pm2 list"'
This single command exercises every link: key auth → CI account → sudo → service user → nvm → PM2. If it returns the correct Node version and process list, the pipeline's deploy stage will work. Debugging here is far cheaper than debugging inside a build.
Credentials → Add → SSH Username with private key
| Username | ubuntu |
| Private key | Enter directly — paste stg_deploy_key contents |
| ID | stg-server-ssh-key |
The deploy stage
stage('Deploy to Staging') {
when { expression { currentBuild.currentResult == 'SUCCESS' } }
steps {
sshagent(['stg-server-ssh-key']) {
sh """
ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} \
'sudo -u ${SERVICE_USER} bash -c "
source ${NVM_DIR}/nvm.sh
set -euo pipefail
cd ${DEPLOY_PATH}
git fetch --all
git reset --hard origin/${DEPLOY_BRANCH}
npm ci
npm run build
pm2 restart ${PM2_APP_NAME}
for i in 1 2 3 4 5; do
if curl -s -o /dev/null -w '%{http_code}' http://localhost:${HEALTH_PORT}/ | grep -q 200; then
echo Service responding on port ${HEALTH_PORT}.
exit 0
fi
sleep 3
done
echo Health check failed after 5 attempts.
exit 1
"'
"""
}
}
}
Quoting — three nested layers
| Layer | Quote | Who expands |
|---|---|---|
| Groovy | """…""" | Jenkins expands ${DEPLOY_HOST} etc. before the shell sees anything |
| Local shell → ssh argument | '…' | Nothing expands locally; the whole string is handed to ssh verbatim |
| Remote bash -c | "…" | Executes on the target host |
${...} inside the triple-double-quoted block. Anything the remote shell must evaluate — like $i in the loop — needs escaping or restructuring so Groovy leaves it alone. When a remote command behaves strangely, print it with echo first and read what actually got sent.set -euo pipefail
-e— exit on any command failure. Without it, a failednpm ciis followed cheerfully bypm2 restart, restarting a half-built application.-u— error on undefined variables, catching typos rather than expanding them to empty strings.-o pipefail— a pipeline fails if any element fails, not just the last one.
git reset --hard rather than git pull
A deploy target should be a exact mirror of the branch, not a merge participant. git pull can produce merge conflicts or merge commits on a server nobody is watching. git fetch --all && git reset --hard origin/<branch> guarantees the working tree matches origin exactly, every time.
Health check
The retry loop with sleep 3 exists because PM2 returns from restart as soon as the process is spawned, not when the application is ready to serve. Without the loop, the check runs against a still-booting app and fails spuriously.
# Preferred — a real health endpoint
curl -sf http://localhost:4016/health
# Fallback when the app has no /health route:
# accept any HTTP 200 from the root path
curl -s -o /dev/null -w '%{http_code}' http://localhost:4016/ | grep -q 200
/ confirms the process is listening and serving — nothing more. It will not catch an app that is up but has lost its database connection. Where no health endpoint exists, record it as a gap and ask for one; do not let the weaker check quietly become the standard.Onboarding a new client
With the pattern established, each additional client is roughly thirty minutes.
| # | Action |
|---|---|
| 1 | Create /etc/sudoers.d/ubuntu-to-<user>, chmod 440, visudo -c |
| 2 | Verify sudo -n -u <user> whoami and the nvm/PM2 chain |
| 3 | Record: project path, PM2 process name, listening port, Node version, deploy branch |
| 4 | Create the SonarQube project, assign the quality gate, grant Execute Analysis |
| 5 | Add the Node tool in Jenkins if that version isn't already registered |
| 6 | Create the Jenkins job in the correct folder, point at the repo and branch |
| 7 | Copy the Jenkinsfile, change only the environment block, commit to the deploy branch |
| 8 | Build once manually and triage |
Discovering the values
# Node version, PM2 processes, and top-level dirs for each service user
for user in kfc dom br phpak phqatar; do
echo "=== $user ==="
sudo -u $user bash -c 'source ~/.nvm/nvm.sh 2>/dev/null && node -v; pm2 list 2>/dev/null | grep -E "online|stopped"'
echo
done
# Which port a process listens on — read it from the app's own startup log
sudo -u kfc bash -c 'source ~/.nvm/nvm.sh && pm2 logs kfc-server --lines 50 --nostream' | grep -i port
# Or from the socket table
ss -lntp | grep 4016
Sudoers policy
Worth writing down, because it is the thing an auditor will ask about:
ubuntu CI account holds NOPASSWD sudo to a service user only where an active CI/CD pipeline exists for that client. Each grant is a separate file in /etc/sudoers.d/ubuntu-to-<user>, so access is individually auditable and revocable. Grants are added as part of pipeline creation and removed when a pipeline is decommissioned.
A one-file-per-user layout means ls /etc/sudoers.d/ is a complete access inventory, and revoking one client's access is a single rm with no risk of breaking the others. If grants are ever made ahead of the pipelines that justify them, record the date and business reason in the change log — an unexplained gap between "users with access" and "clients with pipelines" is exactly what audit findings are made of.
Verification checklist
| # | Check | Pass condition |
|---|---|---|
| 1 | Sudoers parses | visudo -c → parsed OK for every file |
| 2 | Passwordless switch | sudo -n -u <user> whoami prints the username |
| 3 | nvm resolves in non-interactive shell | sudo -u <user> bash -c 'source ~/.nvm/nvm.sh && node -v' |
| 4 | PM2 runs as the service user | pm2 list shows the correct owner in the user column |
| 5 | SSH key auth works | Full chain command from Step 5 returns cleanly |
| 6 | Home directory permissions | ls -ld ~ubuntu → not group-writable |
| 7 | sshd allows the CI account | Check AllowUsers / AllowGroups in sshd_config |
| 8 | Health endpoint responds | curl -i http://localhost:<port>/ → 200 |