This capstone project ties together everything you have learned in this tutorial: user management, groups, permissions, the filesystem, shell configuration, package management, and environment variables. You will provision a fresh Ubuntu 24.04 server for a small engineering team of four roles — an application developer, an ops engineer, a CI/CD service account, and an auditor — each with precisely scoped access and a productive shell environment. This is the kind of task you will repeat on every new server, every new cloud VM, and every Kubernetes node bootstrap script you write at scale.
Project scenario: Your team is deploying a Node.js API to a bare Linux server. Four principals need access: alice (developer), bob (ops), svc-deploy (CI/CD bot — no interactive login), and auditor (read-only compliance reviewer). You will create the users, groups, directory layout, permissions, sudoers rules, shell profiles, and SSH hardening in a single repeatable script.
Step 1 — Create Groups First
Groups are the primary mechanism for access control. Always create groups before users so you can assign primary and supplementary groups at user creation time. Three groups cover the scenario: developers, ops, and auditors.
# Run as root or with sudo
sudo groupadd --gid 2001 developers
sudo groupadd --gid 2002 ops
sudo groupadd --gid 2003 auditors
# Verify
getent group developers ops auditors
Hard-coding GIDs is a production habit. On cloud VMs that are rebuilt frequently, dynamic GIDs can shift between builds, which breaks ownership of files on shared NFS mounts or persisted EBS volumes. Pin GIDs above 2000 to stay clear of system account range (0-999) and dynamic allocation range (1000-1999).
Step 2 — Create Users with Constrained Shells
Each user gets the minimum configuration required for their role. The CI/CD service account svc-deploy never needs an interactive shell — locking it to /usr/sbin/nologin prevents any accidental or malicious interactive login while still allowing ssh-driven command execution via ForceCommand (covered in Step 6).
# alice — developer, interactive login, home directory
sudo useradd \
--uid 2101 \
--gid developers \
--groups auditors \
--create-home \
--shell /bin/bash \
--comment "Alice — Backend Developer" \
alice
# bob — ops, interactive login, also member of developers
sudo useradd \
--uid 2102 \
--gid ops \
--groups developers \
--create-home \
--shell /bin/bash \
--comment "Bob — Platform Ops" \
bob
# svc-deploy — CI/CD bot, NO interactive login, no home needed
sudo useradd \
--uid 2103 \
--gid ops \
--no-create-home \
--shell /usr/sbin/nologin \
--comment "CI/CD deployment service account" \
svc-deploy
# auditor — read-only reviewer, interactive but locked down
sudo useradd \
--uid 2104 \
--gid auditors \
--create-home \
--shell /bin/bash \
--comment "Auditor — Compliance" \
auditor
# Lock password-based auth for all accounts — SSH keys only
sudo passwd --lock alice
sudo passwd --lock bob
sudo passwd --lock auditor
sudo passwd --lock svc-deploy
# Confirm
getent passwd alice bob svc-deploy auditor
Production practice: Disable password authentication for all service and human accounts on servers. Force SSH key authentication in /etc/ssh/sshd_config (PasswordAuthentication no) and manage SSH public keys via your secrets manager or configuration management tool (Ansible, Puppet, Chef). Never distribute shared passwords over chat.
Step 3 — Build the Application Directory Tree
A well-structured directory layout makes permission management unambiguous. The convention below separates immutable application code from runtime data, separates logs from secrets, and ensures the CI/CD account can deploy without touching secrets or logs owned by the runtime user.
POSIX ACLs are the right tool when you need to grant access to a principal that does not share a group with the directory owner. Rather than adding auditor to the ops group (which would grant write access), a targeted setfacl entry gives read-only access. This is how large-scale operations teams grant compliance teams log access without widening the blast radius.
Team permission layout: each user, their group memberships, and the directory ACL model they map to.
Step 4 — Configure sudoers with Least Privilege
Never add users to the sudo group wholesale. Use targeted sudoers drop-in files under /etc/sudoers.d/ to grant only the commands each role legitimately needs. This is the difference between "I gave alice sudo" and "alice can only restart the app service and read nginx logs — nothing else."
# /etc/sudoers.d/ops — bob and the ops group get broad but logged sudo
# Always edit with visudo or write atomically, never raw vi
sudo tee /etc/sudoers.d/ops <<'EOF'
# Ops group: full sudo with password confirmation
%ops ALL=(ALL:ALL) ALL
EOF
# /etc/sudoers.d/svc-deploy — CI/CD account: deploy script only, NOPASSWD
sudo tee /etc/sudoers.d/svc-deploy <<'EOF'
# CI/CD bot: passwordless execution of the deploy script only
svc-deploy ALL=(root) NOPASSWD: /opt/nodeapp/bin/deploy.sh
EOF
# /etc/sudoers.d/developers — restart service only
sudo tee /etc/sudoers.d/developers <<'EOF'
# Developers can restart the app service and tail its log; nothing else
%developers ALL=(root) NOPASSWD: /bin/systemctl restart nodeapp, \
/usr/bin/journalctl -u nodeapp -n 200
EOF
# Lock down sudoers.d permissions — required for sudo to honour them
sudo chmod 440 /etc/sudoers.d/ops /etc/sudoers.d/svc-deploy /etc/sudoers.d/developers
# Validate — syntax check before applying
sudo visudo --check --file=/etc/sudoers.d/ops
Production pitfall: Files in /etc/sudoers.d/ must be owned by root and have mode 0440. A mode of 0644 or world-writable causes sudo to silently ignore the entire file in most distros. After any change, run sudo visudo --check to catch syntax errors before they lock you out of privileged access.
Step 5 — Deploy SSH Public Keys
Distribute SSH public keys programmatically, never manually copy-pasted. The authorized_keys file must live at ~/.ssh/authorized_keys, with strict ownership and mode. Any deviation causes sshd to silently reject the key.
# Helper function — reusable in your bootstrap script
install_ssh_key() {
local user="$1"
local pubkey="$2"
local home
home=$(getent passwd "$user" | cut -d: -f6)
install -d -m 700 -o "$user" -g "$user" "${home}/.ssh"
echo "$pubkey" >> "${home}/.ssh/authorized_keys"
chmod 600 "${home}/.ssh/authorized_keys"
chown "$user":"$user" "${home}/.ssh/authorized_keys"
}
# Call with each user's public key (fetched from your secrets manager or vault)
install_ssh_key alice "ssh-ed25519 AAAA...alice_key alice@laptop"
install_ssh_key bob "ssh-ed25519 AAAA...bob_key bob@workstation"
install_ssh_key auditor "ssh-ed25519 AAAA...auditor_key auditor@audit-host"
# svc-deploy key: restrict to a single command even at the SSH layer
# Prefix the key in authorized_keys with a forced command
sudo -u svc-deploy tee /home/svc-deploy/.ssh/authorized_keys <<'KEYS'
command="/opt/nodeapp/bin/deploy.sh",no-pty,no-agent-forwarding ssh-ed25519 AAAA...ci_key ci@github-actions
KEYS
Step 6 — Harden sshd and Apply Shell Profiles
Drop a configuration snippet into /etc/ssh/sshd_config.d/ (Ubuntu 24.04 includes this directory by default). This avoids editing the base file and makes your hardening change reviewable in git.
sudo tee /etc/ssh/sshd_config.d/99-team-hardening.conf <<'EOF'
# Disable all password and keyboard-interactive auth — keys only
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
# Only allow the four principals and the ops group
AllowUsers alice bob auditor svc-deploy
AllowGroups ops developers auditors
# Short idle timeout — disconnect inactive sessions after 10 min
ClientAliveInterval 300
ClientAliveCountMax 2
# Log every login at verbose level for the auditor
LogLevel VERBOSE
EOF
# Validate config before reload — if this fails, DO NOT reload
sudo sshd -t && sudo systemctl reload ssh
# Set up a shared .bashrc fragment for developers
sudo tee /etc/profile.d/nodeapp-env.sh <<'EOF'
# Shared environment for all interactive sessions on this server
export APP_ENV=production
export APP_ROOT=/opt/nodeapp/current
export PATH="$APP_ROOT/bin:$PATH"
# Colored prompt with hostname and git branch
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]$(git branch 2>/dev/null | grep -o "* .*" | sed "s/* / /")\$ '
EOF
Step 7 — Verify the Entire Setup
Never trust your own configuration script without verifying the results. Run these checks and compare against expected output before handing the server to the team.
# 1. Confirm all users exist and have correct shells
getent passwd alice bob svc-deploy auditor
# 2. Confirm group memberships
for u in alice bob svc-deploy auditor; do
echo "--- $u ---"
id "$u"
done
# 3. Verify directory permissions match the model
stat -c "%a %U:%G %n" /opt/nodeapp \
/opt/nodeapp/releases \
/opt/nodeapp/shared/config \
/opt/nodeapp/shared/logs \
/opt/nodeapp/shared/tmp
# 4. Check ACLs on the logs directory
getfacl /opt/nodeapp/shared/logs
# 5. Verify sudoers parse cleanly
sudo visudo --check
# 6. Test privilege escalation as alice (should succeed for the two allowed cmds)
sudo -u alice sudo -n /bin/systemctl restart nodeapp 2>&1
# 7. Test that auditor cannot write to logs (should fail with Permission denied)
sudo -u auditor touch /opt/nodeapp/shared/logs/test.txt 2>&1
# 8. Confirm sshd config is valid
sudo sshd -t && echo "sshd config OK"
Pro practice: Wrap all of these steps into an idempotent shell script (or better, an Ansible playbook). Idempotent means running it a second time produces no changes and no errors. This is the foundation of configuration as code: every server in your fleet should be provably identical because they were all built from the same script, not because someone SSH'd in and typed the right commands on each one.
What You Built
At the end of this project you have a hardened, role-appropriate Linux environment that mirrors how cloud-native teams actually manage server access. The key decisions you made — and the reasoning behind them — are the same decisions you will make at scale:
Pinned UIDs/GIDs ensure consistent ownership across rebuilt VMs and shared storage.
Locked passwords with SSH-key-only auth eliminates credential stuffing and brute-force attack vectors.
Least-privilege sudoers rules constrain the blast radius of a compromised account.
POSIX ACLs grant cross-group access without widening group membership and unintended write permissions.
sshd drop-in config makes hardening auditable and version-controlled rather than buried in a monolithic config file.
Verification checks turn configuration into a testable artifact — the final step of every real infrastructure change.
This pattern scales from a single VM to an Ansible playbook managing ten thousand nodes. The commands change in syntax; the thinking does not.