Skip to content

07 · System Administration Scripting

Scripts that manage the system itself — creating users, wiring up services, controlling systemd units — affect the whole machine, not just one process, so they deserve extra care. This module covers user/group management, writing service control scripts, and systemd basics for running your own scripts as managed, supervised services.

Creating and managing users

sudo useradd -m -s /bin/bash deploy            # -m creates a home dir, -s sets the login shell
sudo useradd -r -s /usr/sbin/nologin svcacct     # -r = system account, nologin = no interactive shell

sudo usermod -aG docker deploy                     # -aG appends deploy to the docker group (never plain -G!)
sudo userdel -r olduser                                # -r also removes the home directory

-aG (append) is important: plain -G replaces a user's entire group list, silently dropping every group they weren't just added to.

Inspecting users and groups

id deploy                       # uid, gid, and group memberships
getent passwd deploy              # look up a user regardless of backend (files, LDAP, etc.)
getent group docker                 # list members of a group
groups deploy                          # groups deploy belongs to

Scripting user creation idempotently

ensure_user() {
    local username="$1"
    if id "$username" &>/dev/null; then
        echo "user '$username' already exists — skipping"
    else
        useradd -m -s /bin/bash "$username"
        echo "created user '$username'"
    fi
}

ensure_user "deploy"
ensure_user "deploy"    # safe to run again — no error, no duplicate

Idempotent provisioning scripts (safe to re-run any number of times) are the standard for configuration-management style automation — the same principle applies to groups, directories, and package installs.

Password and account state

sudo passwd -l deploy           # lock the account (disable password login)
sudo passwd -u deploy            # unlock it
sudo chage -l deploy               # show password expiry policy
sudo chage -M 90 deploy              # force password rotation every 90 days

Writing a service control wrapper script

#!/usr/bin/env bash
# myappctl — start/stop/status wrapper around a long-running process
set -euo pipefail

PID_FILE="/var/run/myapp.pid"
APP_BIN="/opt/myapp/bin/myapp"

start() {
    if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
        echo "already running (pid $(cat "$PID_FILE"))"
        exit 0
    fi
    nohup "$APP_BIN" > /var/log/myapp.log 2>&1 &
    echo $! > "$PID_FILE"
    echo "started (pid $!)"
}

stop() {
    if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
        kill "$(cat "$PID_FILE")"
        rm -f "$PID_FILE"
        echo "stopped"
    else
        echo "not running"
    fi
}

status() {
    if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
        echo "running (pid $(cat "$PID_FILE"))"
    else
        echo "stopped"
    fi
}

case "${1:-}" in
    start) start ;;
    stop) stop ;;
    status) status ;;
    restart) stop; start ;;
    *) echo "Usage: $0 {start|stop|status|restart}" >&2; exit 1 ;;
esac

This pid-file pattern is exactly what systemd was built to replace — no manual kill -0 checks, no stale pid files after a crash.

systemd basics: unit files

# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target

[Service]
Type=simple
ExecStart=/opt/myapp/bin/myapp
Restart=on-failure
User=myapp
Group=myapp

[Install]
WantedBy=multi-user.target

Controlling a systemd service

sudo systemctl daemon-reload           # re-read unit files after editing one
sudo systemctl start myapp
sudo systemctl enable myapp              # start automatically on boot
sudo systemctl status myapp
sudo systemctl restart myapp
sudo systemctl stop myapp
journalctl -u myapp -f                     # follow this service's logs live

systemd timers: a cron alternative

# /etc/systemd/system/myapp-backup.timer
[Unit]
Description=Run myapp backup daily

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl enable --now myapp-backup.timer
systemctl list-timers                        # see all scheduled timers and their next run

A .timer unit pairs with a matching myapp-backup.service (Type=oneshot) — the timer only decides when to start it, while systemd's own logging, restart, and dependency handling give you more than a raw crontab line does.

How It Actually Works

A systemd timer doesn't replace cron's model so much as split it into two cooperating unit files evaluated by a much more capable central daemon (systemd as PID 1's service manager) — the .timer unit registers a wake condition with systemd's internal event scheduler (calendar-based like cron, or monotonic like "5 minutes after boot"), and when it fires, systemd starts the paired .service unit using the exact same fork()+execve() process-creation path as any other systemd service, but critically inside a cgroup systemd creates and tracks — which is why systemd can enforce resource limits, capture structured logs via the journal, and know definitively whether the job succeeded, in ways a bare cron-spawned process cannot.

Because each timer-triggered service is its own tracked systemd unit, its environment is defined entirely by the unit file (Environment=, EnvironmentFile=) rather than inherited from an interactive shell at all — there's no .bashrc/login-shell ambiguity like with cron, because systemd never spawns anything through a login shell in the first place; the "minimal environment" surprise from cron becomes, under systemd, an explicit and inspectable configuration rather than an implicit inheritance quirk.

Log rotation tools like logrotate rely on the same delete-doesn't-immediately-free-space inode behavior covered earlier: they typically rename the current log out of the way and signal (via SIGHUP or a reload command) the writing process to reopen its log file by path, because the old file descriptor the process is still holding keeps pointing at the renamed (soon rotated-away) inode until it's explicitly told to reopen and get a fresh descriptor onto the new path.

Cheat sheet

Command Purpose
useradd -m -s /bin/bash user create a user with a home dir and shell
usermod -aG group user add a user to a group (append, don't replace)
userdel -r user delete a user and their home dir
id user / getent passwd user inspect a user's identity
systemctl start/stop/status svc control a systemd service
systemctl enable svc start automatically on boot
journalctl -u svc -f follow a service's logs
*.timer unit systemd's cron-like scheduling primitive

Exercise

Write provision_user.sh that takes a username and a group name as arguments, creates the user idempotently (skipping if it already exists), adds them to the group (creating the group first if it doesn't exist), and locks the password so only key-based or sudo access works. Then write a systemd unit file for a script of your choice and enable it to start on boot.