Linux Fundamentals

Project: Set Up a Linux Server User Environment

35 min Lesson 10 of 26

Project: Set Up a Linux Server User Environment

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.

# Application root — owned by root, readable by ops + developers sudo mkdir -p /opt/nodeapp/{releases,current,shared/{config,logs,tmp}} # Set directory ownership sudo chown -R root:ops /opt/nodeapp sudo chown -R root:developers /opt/nodeapp/releases sudo chown -R bob:ops /opt/nodeapp/shared/logs # Permission model: # /opt/nodeapp root:ops 750 — ops can traverse, others cannot # releases/ root:devs 755 — developers read/list releases # shared/config root:ops 750 — secrets readable only by ops group # shared/logs bob:ops 775 — ops group writes logs # shared/tmp root:ops 1777 — sticky-bit tmp inside the app tree sudo chmod 750 /opt/nodeapp sudo chmod 755 /opt/nodeapp/releases sudo chmod 750 /opt/nodeapp/shared/config sudo chmod 775 /opt/nodeapp/shared/logs sudo chmod 1777 /opt/nodeapp/shared/tmp # Auditor needs read-only access to logs — use ACLs (requires acl package) sudo apt-get install -y acl sudo setfacl -Rm u:auditor:r-x /opt/nodeapp/shared/logs sudo setfacl -dm u:auditor:r-x /opt/nodeapp/shared/logs # default — applies to new files # Verify ACLs getfacl /opt/nodeapp/shared/logs

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 Access Layout: Users, Groups and Directory Permissions Users Groups Directories / ACL alice bob svc-deploy auditor developers ops auditors releases/ root:developers 755 shared/config root:ops 750 shared/logs bob:ops 775 shared/logs + ACL auditor:r-x shared/tmp root:ops 1777 Primary group Supplementary group
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.