Automate Maintenance on a Debian LAMP Stack Using a Bash Script

Keeping a production server patched and clean requires running the same small set of commands on a regular schedule. When performed manually, these routine tasks are easy to overlook or postpone. A bash script added to the system crontab handles them automatically and logs the results for review.

This guide covers writing a maintenance script for the Debian 12 LAMP stack built in Install and Harden a Debian 12 LAMP Stack on Amazon Lightsail. The script takes a Lightsail snapshot before patching, prunes old snapshots to control storage costs, updates installed packages, conditionally restarts PHP-FPM, Apache, and MariaDB only when relevant packages changed, cleans up stale package files, removes old log files, and alerts when disk usage exceeds a configurable threshold. A --dry-run flag lets you preview every action before committing to a live run.

Prerequisites

This guide assumes the AWS CLI is installed and configured with credentials that have permission to create and delete Lightsail snapshots. If the AWS CLI is not yet installed, run:

sudo apt install awscli -y

Then configure credentials:

aws configure

Provide an AWS access key ID and secret access key for an IAM user or role with at least the lightsail:CreateInstanceSnapshot, lightsail:GetInstanceSnapshots, and lightsail:DeleteInstanceSnapshot permissions. The credentials are stored in ~/.aws/credentials for the current user and reused automatically by subsequent AWS CLI commands.

To verify the configuration is working before proceeding:

aws lightsail get-instances --query 'instances[*].name'

The output should list your Lightsail instance names.

The script uses sudo for apt, systemctl, and rm commands. On a default Lightsail Debian instance, the default user has passwordless sudo configured, which allows these commands to run unattended in a cron job. If sudoers has been tightened to require a password, the cron job will fail silently on every sudo command. Verify that passwordless sudo is in place for the relevant commands before scheduling the script.

NOTE: The snapshot steps require the instance name as it appears in the Lightsail console. The script uses a variable for this so set it to your actual instance name before running the script for the first time.

What the Maintenance Script Does

The script performs seven tasks on each run:

Create the Script

Create a scripts directory in the home directory and set permissions so only the owner can read, write, or execute it:

mkdir -m 700 ~/scripts

Create the script file and set the same permissions:

touch ~/scripts/maintenance.sh
chmod 700 ~/scripts/maintenance.sh

Open the file for editing:

nano ~/scripts/maintenance.sh

Copy the full script from the Full Source Code section at the end of this article into the file. Replace your-instance-name in the configuration section with the name of your Lightsail instance as it appears in the console, then save the file.

NOTE: If you upgrade to a newer PHP version in the future, update the PHP_VERSION variable at the top of the script to match. See Upgrade PHP on a Debian LAMP Stack for the full upgrade process. Updating the single variable at the top of the script is all that is required.

Dry Run

Before running the script against a live server, use the --dry-run flag to preview every action without making any changes:

bash ~/scripts/maintenance.sh --dry-run

In dry-run mode, each state-changing command is printed to the terminal prefixed with [DRY RUN] Would run: rather than executed. Read-only commands such as snapshot listing and disk usage checks run normally so the output reflects actual system state. The log file is not written to in dry-run mode; the log() helper routes output to stdout instead.

The find subshells inside the log cleanup rm commands resolve before the run wrapper intercepts them, so dry-run output shows the actual filenames that would be deleted rather than the find expression. This is useful as it lets you confirm which log files would be removed before the command runs.

The snapshot listing command that drives pruning is read-only and intentionally runs in dry-run mode so the output shows which snapshots would be deleted.

Example dry-run output for a run where PHP packages were updated:

=== DRY RUN MODE: No changes will be made ===
[2026-06-29 03:00:01] === Maintenance started ===
[2026-06-29 03:00:01] Creating snapshot: your-instance-name-maintenance-20260629
[DRY RUN] Would run: aws lightsail create-instance-snapshot --instance-name your-instance-name --instance-snapshot-name your-instance-name-maintenance-20260629
[2026-06-29 03:00:02] Snapshot creation initiated.
[2026-06-29 03:00:02] Pruning old maintenance snapshots (retaining 4 most recent)...
[2026-06-29 03:00:03] Deleting old snapshot: your-instance-name-maintenance-20260525
[DRY RUN] Would run: aws lightsail delete-instance-snapshot --instance-snapshot-name your-instance-name-maintenance-20260525
[2026-06-29 03:00:03] Deleted snapshot: your-instance-name-maintenance-20260525
[2026-06-29 03:00:03] Snapshot pruning complete. Retained 4 most recent snapshots.
[2026-06-29 03:00:03] Running apt update...
[DRY RUN] Would run: sudo apt update
[2026-06-29 03:00:03] Running apt upgrade...
[DRY RUN] Would run: sudo apt upgrade -y
[DRY RUN] Would check for PHP package updates and restart php8.4-fpm if needed.
[DRY RUN] Would check for Apache package updates and restart apache2 if needed.
[DRY RUN] Would check for MariaDB package updates and restart mariadb if needed.
[2026-06-29 03:00:03] Running apt autoclean...
[DRY RUN] Would run: sudo apt autoclean
[2026-06-29 03:00:03] Cleaning up old Apache log files...
[DRY RUN] Would run: sudo rm -f /var/log/apache2/access.log.6.gz /var/log/apache2/error.log.6.gz
[2026-06-29 03:00:03] Cleaning up old PHP-FPM log files...
[DRY RUN] Would run: sudo rm -f /var/log/php8.4-fpm.log.6.gz
[2026-06-29 03:00:04] Current disk usage on /: 34%
[2026-06-29 03:00:04] === Maintenance completed ===

Verify the Script Runs Correctly

After reviewing the dry-run output, run the script to confirm it executes without errors:

bash ~/scripts/maintenance.sh

Review the log file to confirm each step completed successfully:

cat ~/scripts/maintenance.log

The log shows timestamped entries for each step. If PHP-FPM, Apache, or MariaDB fail to restart, the log includes an ERROR line identifying which service failed. Run sudo systemctl status php8.4-fpm, sudo systemctl status apache2, or sudo systemctl status mariadb to investigate before proceeding.

Confirm the snapshot was created in the Lightsail console under the Snapshots tab, or verify via the CLI:

aws lightsail get-instance-snapshots --query 'instanceSnapshots[*].[name,state]'

NOTE: Lightsail snapshot creation is asynchronous. The create-instance-snapshot command returns after the snapshot request has been accepted, not after the snapshot has finished. Wait until the snapshot state is available before relying on it as a restore point.

Schedule the Script With cron

Open the current user’s crontab for editing:

crontab -e

Add the following line to run the script at 3:00 AM every Sunday:

0 3 * * 0 /bin/bash /home/username/scripts/maintenance.sh

Replace username with the actual system username. Using the full path to both bash and the script avoids PATH resolution issues that can cause cron jobs to fail silently.

NOTE: The script writes its log to ~/scripts/maintenance.log. In a cron context, ~ resolves to the home directory of the user running the cron job. Ensure the cron entry runs as the same user who created the script, or replace ~ in the LOG_FILE variable with an absolute path such as /home/username/scripts/maintenance.log.

Save and exit the editor. cron will now run the maintenance script automatically each week.

Review the Log

After the first scheduled run, review the log to confirm the script executed as expected:

cat ~/scripts/maintenance.log

Each run appends a timestamped block to the log file. Over time the file will grow; truncate it periodically with truncate -s 0 ~/scripts/maintenance.log or add a logrotate entry to rotate and compress it automatically.

Full Source Code

#!/bin/bash

# -------------------------------------------------------
# Configuration
# -------------------------------------------------------
INSTANCE_NAME="your-instance-name"
PHP_VERSION="8.4"
LOG_FILE=~/scripts/maintenance.log
SNAPSHOT_NAME="${INSTANCE_NAME}-maintenance-$(date +%Y%m%d)"

# Number of weekly maintenance snapshots to retain.
# Snapshots older than this count will be deleted.
# Lightsail charges for snapshot storage; adjust based
# on your retention needs and cost tolerance.
SNAPSHOT_RETAIN=4

# Disk usage percentage threshold for the root filesystem.
# A warning is written to the log if usage exceeds this value.
DISK_WARN_THRESHOLD=80

# -------------------------------------------------------
# Dry-run mode
# -------------------------------------------------------
DRY_RUN=false
if [ "$1" = "--dry-run" ]; then
    DRY_RUN=true
    echo "=== DRY RUN MODE: No changes will be made ==="
fi

# Wrap commands that make changes with this function.
# In dry-run mode, the command is printed but not executed.
# Note: find subshells in rm commands resolve before run
# intercepts them, so actual filenames appear in dry-run
# output rather than the find expression. This is intentional.
run() {
    if [ "$DRY_RUN" = true ]; then
        echo "[DRY RUN] Would run: $*"
    else
        "$@"
    fi
}

# -------------------------------------------------------
# Logging helper
# In dry-run mode, output goes to stdout rather than the
# log file so no changes are made to the filesystem.
# -------------------------------------------------------
log() {
    if [ "$DRY_RUN" = false ]; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
    else
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
    fi
}

log "=== Maintenance started ==="

# -------------------------------------------------------
# Step 1: Take a Lightsail snapshot before patching
# -------------------------------------------------------
log "Creating snapshot: ${SNAPSHOT_NAME}"
run aws lightsail create-instance-snapshot \
    --instance-name "$INSTANCE_NAME" \
    --instance-snapshot-name "$SNAPSHOT_NAME"

if [ $? -ne 0 ] && [ "$DRY_RUN" = false ]; then
    log "WARNING: Snapshot creation failed. Proceeding with updates anyway."
else
    log "Snapshot creation initiated."
fi

# -------------------------------------------------------
# Step 2: Prune old maintenance snapshots
# This listing command is read-only and intentionally runs
# in dry-run mode so the output shows which snapshots
# would be deleted.
# -------------------------------------------------------
log "Pruning old maintenance snapshots (retaining ${SNAPSHOT_RETAIN} most recent)..."

SNAPSHOTS=$(aws lightsail get-instance-snapshots \
    --query "instanceSnapshots[?contains(name, '${INSTANCE_NAME}-maintenance-')]|sort_by(@, &createdAt)|reverse(@)[*].name" \
    --output text 2>> "$LOG_FILE")

if [ -z "$SNAPSHOTS" ]; then
    log "No maintenance snapshots found to prune."
else
    COUNT=0
    for SNAP in $SNAPSHOTS; do
        COUNT=$((COUNT + 1))
        if [ $COUNT -gt $SNAPSHOT_RETAIN ]; then
            log "Deleting old snapshot: ${SNAP}"
            run aws lightsail delete-instance-snapshot \
                --instance-snapshot-name "$SNAP"
            if [ $? -ne 0 ] && [ "$DRY_RUN" = false ]; then
                log "WARNING: Failed to delete snapshot ${SNAP}."
            else
                log "Deleted snapshot: ${SNAP}"
            fi
        fi
    done
    log "Snapshot pruning complete. Retained ${SNAPSHOT_RETAIN} most recent snapshots."
fi

# -------------------------------------------------------
# Step 3: Update package index and upgrade packages
# -------------------------------------------------------
log "Running apt update..."
run sudo apt update

log "Running apt upgrade..."
if [ "$DRY_RUN" = true ]; then
    echo "[DRY RUN] Would run: sudo apt upgrade -y"
    UPGRADE_OUTPUT=""
else
    UPGRADE_OUTPUT=$(sudo apt upgrade -y 2>&1)
    echo "$UPGRADE_OUTPUT" >> "$LOG_FILE"

    if echo "$UPGRADE_OUTPUT" | grep -qi "error"; then
        log "WARNING: apt upgrade reported errors. Review the log above."
    fi
fi

# -------------------------------------------------------
# Step 4: Conditionally restart services
# -------------------------------------------------------

# Restart PHP-FPM only if a PHP package was upgraded
if [ "$DRY_RUN" = false ] && echo "$UPGRADE_OUTPUT" | grep -q "php${PHP_VERSION}"; then
    log "PHP packages updated. Restarting php${PHP_VERSION}-fpm..."
    run sudo systemctl restart php${PHP_VERSION}-fpm
    if [ $? -ne 0 ]; then
        log "ERROR: php${PHP_VERSION}-fpm failed to restart. Check systemctl status."
    else
        log "php${PHP_VERSION}-fpm restarted successfully."
    fi
elif [ "$DRY_RUN" = true ]; then
    echo "[DRY RUN] Would check for PHP package updates and restart php${PHP_VERSION}-fpm if needed."
else
    log "No PHP packages updated. Skipping php${PHP_VERSION}-fpm restart."
fi

# Restart Apache only if an Apache package was upgraded
if [ "$DRY_RUN" = false ] && echo "$UPGRADE_OUTPUT" | grep -q "apache2"; then
    log "Apache packages updated. Restarting apache2..."
    run sudo systemctl restart apache2
    if [ $? -ne 0 ]; then
        log "ERROR: apache2 failed to restart. Check systemctl status."
    else
        log "apache2 restarted successfully."
    fi
elif [ "$DRY_RUN" = true ]; then
    echo "[DRY RUN] Would check for Apache package updates and restart apache2 if needed."
else
    log "No Apache packages updated. Skipping apache2 restart."
fi

# Restart MariaDB only if a MariaDB package was upgraded.
# MariaDB restarts drop active database connections; the
# conditional check ensures this only happens when an
# update actually requires it.
if [ "$DRY_RUN" = false ] && echo "$UPGRADE_OUTPUT" | grep -q "mariadb"; then
    log "MariaDB packages updated. Restarting mariadb..."
    run sudo systemctl restart mariadb
    if [ $? -ne 0 ]; then
        log "ERROR: mariadb failed to restart. Check systemctl status."
    else
        log "mariadb restarted successfully."
    fi
elif [ "$DRY_RUN" = true ]; then
    echo "[DRY RUN] Would check for MariaDB package updates and restart mariadb if needed."
else
    log "No MariaDB packages updated. Skipping mariadb restart."
fi

# -------------------------------------------------------
# Step 5: Clean up stale package files
# -------------------------------------------------------
log "Running apt autoclean..."
run sudo apt autoclean

# -------------------------------------------------------
# Step 6: Remove old log files
# The find subshells resolve before run intercepts the
# command, so dry-run output shows the actual filenames
# that would be deleted.
# -------------------------------------------------------
log "Cleaning up old Apache log files..."
run sudo rm -f $(find /var/log/apache2 -maxdepth 1 -name '*.log.*' -type f | sort -r | tail -n +6)

log "Cleaning up old PHP-FPM log files..."
run sudo rm -f $(find /var/log -maxdepth 1 -name "php${PHP_VERSION}-fpm*" -type f 2>/dev/null | sort -r | tail -n +6)

# -------------------------------------------------------
# Step 7: Check disk usage
# -------------------------------------------------------
DISK_USAGE=$(df / | awk 'NR==2 {gsub(/%/,"",$5); print $5}')
log "Current disk usage on /: ${DISK_USAGE}%"

if [ "$DISK_USAGE" -ge "$DISK_WARN_THRESHOLD" ]; then
    log "WARNING: Disk usage (${DISK_USAGE}%) has exceeded the ${DISK_WARN_THRESHOLD}% threshold. Review disk usage with: df -h"
fi

log "=== Maintenance completed ==="
if [ "$DRY_RUN" = false ]; then
    echo "" >> "$LOG_FILE"
fi

Design Notes

Several decisions in this script are worth explaining.

Why dry-run mode matters for a production maintenance script

An automated script that runs apt upgrade, restarts services, deletes snapshots, and removes log files is making consequential changes to a production server. The --dry-run flag gives readers a way to verify that the script is configured correctly and will behave as expected before committing to a live run. This is especially important for the snapshot pruning step, where a misconfigured INSTANCE_NAME variable could match unintended snapshots. Reviewing dry-run output before the first scheduled run is a low-cost way to catch configuration mistakes before they affect a live server.

Why take a snapshot before patching rather than after

A snapshot taken after a successful patch run captures a clean post-patch state, which seems useful. The more valuable recovery point is immediately before any changes are applied. If a package upgrade breaks a service, the pre-patch snapshot is what allows a full rollback. A post-patch snapshot of a broken server has limited value. The snapshot is initiated at the start of the script so it reflects the last known good state of the instance.

Why prune snapshots automatically

Lightsail charges for snapshot storage. Without pruning, weekly maintenance snapshots accumulate indefinitely and the storage cost compounds over time with no corresponding benefit; a six-month-old snapshot is rarely useful for recovering from a patch failure. The script retains four snapshots by default, providing roughly a month of rollback history. The SNAPSHOT_RETAIN variable at the top of the script makes it easy to increase or decrease this based on cost tolerance and recovery requirements. The pruning logic filters specifically for snapshots whose names contain -maintenance-, so manually created snapshots are never touched.

Why apt upgrade rather than apt full-upgrade

apt upgrade upgrades installed packages but never removes existing packages or installs new ones to satisfy dependencies. apt full-upgrade will remove packages if that is what dependency resolution requires. For a scheduled production script running unattended, apt upgrade is the safer choice. It applies security patches without the risk of removing a package the application depends on. apt full-upgrade is appropriate for major distribution upgrades handled manually and with a snapshot in place. This distinction is covered in more detail in Upgrade PHP on a Debian LAMP Stack.

Why apt autoclean rather than apt autoremove

apt autoclean removes obsolete package files from the local APT cache without affecting installed software. Although apt autoremove is generally safe, it removes packages that APT considers no longer necessary. For an unattended maintenance script running on a production server, this guide favors the more conservative approach of reclaiming disk space without automatically uninstalling packages. Any package removals can be reviewed and performed manually after major upgrades or other planned maintenance.

Why restart PHP-FPM before Apache

PHP-FPM runs as a separate service and exposes a Unix socket that Apache connects to via the FastCGI proxy. Restarting PHP-FPM first ensures the socket is ready before Apache attempts to reconnect to it. Restarting Apache first can produce a brief window where Apache is running but the PHP-FPM socket is unavailable, causing 503 errors for any requests that arrive during that window. The install guide establishes this ordering for the same reason.

Why conditional restarts rather than always restarting

Restarting PHP-FPM, Apache, and MariaDB terminates active connections and briefly interrupts service. On a week where no relevant packages were updated, those restarts accomplish nothing and carry unnecessary risk. The script checks the apt upgrade output for package names before deciding whether to restart, so services are only interrupted when an update actually requires it. MariaDB restarts are particularly disruptive since active database connections are dropped rather than handled gracefully, which makes the conditional check especially important for that service.

Why package detection uses simple pattern matching

The script determines whether to restart PHP-FPM, Apache, or MariaDB by searching the apt upgrade output for package names associated with each service. This intentionally avoids more complex package parsing logic. Debian package names for these components include identifiers such as php<version>, apache2, and mariadb, making a simple pattern match sufficient for deciding whether a restart is warranted while keeping the script easy to read and maintain.

Why the Sury repository matters here

unattended-upgrades applies automatic security updates only to repositories explicitly listed in its allowed origins configuration. The Sury repository that provides PHP 8.4 is not included by default, which means PHP security updates are silently skipped if you rely on unattended-upgrades alone. This script runs apt upgrade unconditionally across all configured repositories, so Sury updates are applied on the same weekly schedule as everything else without any additional configuration.

Why keep only the five most recent log files

Apache and PHP-FPM rotate logs daily by default via logrotate, which means rotated log files accumulate indefinitely unless removed. On a small Lightsail instance with limited disk space, a busy or misbehaving site can generate enough log volume to fill the disk over weeks or months. Keeping the five most recent rotated files provides roughly a week of history for debugging while preventing unbounded growth. Adjust the tail -n +6 value to retain more or fewer files based on available disk space and debugging needs.

Why check disk usage at the end rather than the beginning

Checking disk usage at the end of the script reflects the state of the filesystem after log cleanup has run, which is the most accurate picture of ongoing disk pressure. A high reading at that point means the cleanup step did not resolve the problem and something else is consuming space (e.g., uploaded content, database growth, or an application generating its own logs outside the standard paths). A high reading before cleanup could be partially attributable to the log files the script is about to remove, which would be a false alarm.

Summary

The script takes a pre-patch snapshot, prunes old snapshots to control storage costs, keeps the server patched across all configured repositories including Sury, restarts services conditionally and in the correct order, clears stale APT cache entries, trims old log files, and checks disk usage after cleanup. The --dry-run flag lets you preview every action before the first live run. The maintenance log provides a timestamped record of each run, and the pre-patch snapshot provides a rollback point if any update causes problems.