qasim@wiki:~$

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.

The hosting model

One staging host runs API servers for many clients. Each client is isolated by Linux user account:

Staging host 10.10.1.223 /home/kfc/ ← service user: kfc · nvm 24.17.0 · pm2: kfc-server :4016 /home/dom/ ← service user: dom · nvm 20.20.2 · pm2: dom-server :4014 /home/br/ ← service user: br · nvm 24.18.0 · pm2: br-server, br-web /home/phpak/ ← service user: phpak · nvm 24.18.0 · pm2: phpak-server, phweb /home/phqatar/ ← service user: phqatar · nvm 24.18.0 · pm2: phqatar-server … /home/ubuntu/ ← CI deploy account. Owns nothing. Can become each service user.

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

ApproachProblem
Jenkins SSHes as rootCI 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 userRequires 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 userOne key to manage. Access is granted per service user via sudoers.d and is individually auditable and revocable.

The deploy chain

Jenkins ──ssh key──▶ ubuntu@10.10.1.223 │ │ sudo -u kfc (NOPASSWD, scoped in /etc/sudoers.d/ubuntu-to-kfc) kfc shell │ source /home/kfc/.nvm/nvm.shgit reset --hard · npm ci · npm run buildpm2 restart kfc-server health check on :4016

Setup — per client

Step 1 — Grant scoped sudo

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
Do not close your SSH session until 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.
Step 2 — Verify the switch works without a password
# -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.

Step 3 — Verify nvm and PM2 in the service user context
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.

Step 4 — SSH key for the CI account
# 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.

Step 5 — Test the full chain from Jenkins
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.

Step 6 — Add the key to Jenkins credentials

Credentials → Add → SSH Username with private key

Usernameubuntu
Private keyEnter directly — paste stg_deploy_key contents
IDstg-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

LayerQuoteWho 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
Rule of thumb. Anything Jenkins should substitute goes in ${...} 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

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.

This discards local changes on the target. That is the intent — a deploy directory should never contain hand edits. If it does, that is a finding to fix, not a reason to soften the deploy.

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
Be honest about what a fallback check proves. A 200 on / 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
1Create /etc/sudoers.d/ubuntu-to-<user>, chmod 440, visudo -c
2Verify sudo -n -u <user> whoami and the nvm/PM2 chain
3Record: project path, PM2 process name, listening port, Node version, deploy branch
4Create the SonarQube project, assign the quality gate, grant Execute Analysis
5Add the Node tool in Jenkins if that version isn't already registered
6Create the Jenkins job in the correct folder, point at the repo and branch
7Copy the Jenkinsfile, change only the environment block, commit to the deploy branch
8Build 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:

Policy. The 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

#CheckPass condition
1Sudoers parsesvisudo -cparsed OK for every file
2Passwordless switchsudo -n -u <user> whoami prints the username
3nvm resolves in non-interactive shellsudo -u <user> bash -c 'source ~/.nvm/nvm.sh && node -v'
4PM2 runs as the service userpm2 list shows the correct owner in the user column
5SSH key auth worksFull chain command from Step 5 returns cleanly
6Home directory permissionsls -ld ~ubuntu → not group-writable
7sshd allows the CI accountCheck AllowUsers / AllowGroups in sshd_config
8Health endpoint respondscurl -i http://localhost:<port>/ → 200