Debian Server Setup: Von der Installation bis zum Production-Ready
Infrastruktur

Debian Server Setup: Von der Installation bis zum Production-Ready

Carola Schulte
31. März 2025
20 min

Debian Server Setup: Von der Installation bis zum Production-Ready

Ein frisch installierter Debian-Server ist wie ein leeres Haus - es hat Wände und ein Dach, aber noch keine Schlösser, keine Alarmanlage und keine Möbel. In diesem Artikel führe ich Sie durch den kompletten Setup-Prozess für einen PHP-Applikationsserver - aus der Perspektive eines Network Security Engineers.

Das Ziel: Ein gehärteter Server, der Nginx, PHP-FPM und PostgreSQL betreibt, mit ordentlicher Firewall und Monitoring.


Überblick: Was wir aufsetzen

┌─────────────────────────────────────────────────────────────────────────────┐
│                    PRODUCTION-READY DEBIAN SERVER                            │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │                      FIREWALL (UFW / nftables)                       │   │
│   │   Ports nach außen: 22 (SSH), 80 (HTTP), 443 (HTTPS)                │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
│                                    │                                        │
│   ┌────────────────────────────────▼────────────────────────────────────┐   │
│   │                         NGINX (Reverse Proxy)                        │   │
│   │   TLS Termination │ Rate Limiting │ Security Headers                │   │
│   └────────────────────────────────┬────────────────────────────────────┘   │
│                                    │                                        │
│   ┌────────────────────────────────▼────────────────────────────────────┐   │
│   │                         PHP-FPM (8.3)                                │   │
│   │   Pool pro App │ Opcache │ Separate User                            │   │
│   └────────────────────────────────┬────────────────────────────────────┘   │
│                                    │                                        │
│   ┌────────────────────────────────▼────────────────────────────────────┐   │
│   │                         PostgreSQL 16                                │   │
│   │   Nur localhost/Socket │ scram-sha-256 │ Backup + WAL               │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
│                                                                              │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │   MONITORING: node_exporter │ Promtail │ Fail2Ban │ Logwatch        │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Teil 1: Basis-Installation und Härtung

Nach der Debian-Installation: Erste Schritte

Direkt nach der Installation - noch als root auf der Konsole:

# System aktualisieren
apt update && apt upgrade -y

# Essenzielle Tools
apt install -y \
    curl wget gnupg2 \
    vim htop tree \
    git unzip \
    sudo ufw fail2ban \
    apt-transport-https ca-certificates

Unprivilegierten Admin-User anlegen

Niemals als root arbeiten - auch nicht per SSH:

# User anlegen
adduser deploy

# Zur sudo-Gruppe hinzufügen
usermod -aG sudo deploy

# SSH-Key einrichten (von Ihrem lokalen Rechner)
# Auf dem Server:
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh

# Ihren Public Key einfügen
vim /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh

SSH härten

Die SSH-Konfiguration ist der wichtigste Angriffspunkt:

# /etc/ssh/sshd_config - Änderungen:

# Root-Login verbieten
PermitRootLogin no

# Nur Key-Auth, kein Passwort
PasswordAuthentication no
PubkeyAuthentication yes

# Nur bestimmte User (bei mehreren Admins: AllowUsers deploy otheradmin)
AllowUsers deploy

# Idle-Timeout
ClientAliveInterval 300
ClientAliveCountMax 2

# Sicherheits-Optionen
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no

# Krypto explizit festzurren (alte Clients fliegen raus - meist egal)
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com

# Optional: Port ändern (Security through Obscurity, aber reduziert Noise)
# Port 2222
# WICHTIG: Bei Port-Änderung UFW anpassen!
# ufw allow 2222/tcp && ufw delete allow 22/tcp

# TCP-Forwarding: Global aus, nur für deploy lokal an (für Monitoring-Tunnel)
Match User deploy
    AllowTcpForwarding local
    PermitOpen 127.0.0.1:9100 127.0.0.1:8080
    GatewayPorts no
# Konfiguration testen und neu laden
sshd -t && systemctl reload sshd

Wichtig: Testen Sie in einer zweiten SSH-Session, ob der Login noch funktioniert, bevor Sie die aktuelle Session schließen!

Firewall mit UFW

UFW ist ein einfaches Frontend für nftables/iptables:

# Standard-Policy
ufw default deny incoming
ufw default allow outgoing

# SSH erlauben (WICHTIG: Vor dem Enable!)
ufw allow 22/tcp

# HTTP/HTTPS
ufw allow 80/tcp
ufw allow 443/tcp

# Aktivieren
ufw enable

# Status prüfen
ufw status verbose

Erweiterte Regeln (Rate Limiting für SSH):

# SSH mit Rate Limiting (max 6 Verbindungen in 30 Sekunden)
ufw delete allow 22/tcp
ufw limit 22/tcp

Hinweis: UFW limit und Fail2Ban zusammen ist okay - UFW limit reduziert Noise (kurzfristig), Fail2Ban sperrt persistent (langfristig).

Fail2Ban konfigurieren

Fail2Ban sperrt IPs nach fehlgeschlagenen Login-Versuchen:

# /etc/fail2ban/jail.local

[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1

[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 24h

# Recidive: Wer wiederkommt, bleibt länger weg
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
bantime = 7d
findtime = 1d
maxretry = 5
# Aktivieren
systemctl enable fail2ban
systemctl start fail2ban

# Status prüfen
fail2ban-client status sshd

Kernel-Hardening (Optional)

# /etc/sysctl.d/99-hardening.conf

# IP Spoofing / Redirects verhindern
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0

# SYN Flood Basics
net.ipv4.tcp_syncookies = 1

# IPv6 deaktivieren wenn nicht gebraucht
# net.ipv6.conf.all.disable_ipv6 = 1
sysctl -p /etc/sysctl.d/99-hardening.conf

Automatische Sicherheitsupdates

# Unattended-upgrades installieren
apt install -y unattended-upgrades apt-listchanges

# Konfigurieren
dpkg-reconfigure -plow unattended-upgrades
# /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}";
    "${distro_id}:${distro_codename}-security";
    "${distro_id}:${distro_codename}-updates";
};

Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";

// Mail-Benachrichtigung
Unattended-Upgrade::Mail "admin@example.com";
Unattended-Upgrade::MailReport "on-change";

journald begrenzen

Verhindert “Warum ist / voll?” um 3 Uhr nachts:

# /etc/systemd/journald.conf
[Journal]
SystemMaxUse=1G
RuntimeMaxUse=512M
MaxRetentionSec=14day
systemctl restart systemd-journald

Teil 2: Nginx als Reverse Proxy

Installation

# Nginx installieren
apt install -y nginx

# Standardseite deaktivieren
rm /etc/nginx/sites-enabled/default

Globale Security-Einstellungen

# /etc/nginx/conf.d/security.conf

# Server-Token verstecken
server_tokens off;

# Request Size begrenzen (für Uploads gezielt erhöhen, nicht global)
client_max_body_size 64m;
client_body_timeout 15s;

# Click-Jacking verhindern
add_header X-Frame-Options "SAMEORIGIN" always;

# MIME-Sniffing verhindern
add_header X-Content-Type-Options "nosniff" always;

# Referrer-Policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# HSTS (nur wenn HTTPS wirklich stabil steht - Fehler "kleben" lange!)
# add_header Strict-Transport-Security "max-age=31536000" always;
# Später ggf.: includeSubDomains; preload

# Content-Security-Policy (Basis - an App anpassen!)
# add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;

# SSL-Einstellungen (global)
# Cipher-Suite am besten via Mozilla SSL Config Generator generieren
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;

# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
# Resolver an Umgebung anpassen (Company DNS / systemd-resolved / Public DNS)
resolver 1.1.1.1 8.8.8.8 valid=300s;

# DH-Parameter (einmal generieren: openssl dhparam -out /etc/nginx/dhparam.pem 2048)
# ssl_dhparam /etc/nginx/dhparam.pem;

Rate Limiting konfigurieren

# /etc/nginx/conf.d/rate-limiting.conf

# Limit-Zonen definieren
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;

# Connection Limits
limit_conn_zone $binary_remote_addr zone=addr:10m;

Basis-Vhost für PHP-Apps

# /etc/nginx/sites-available/app.example.com

server {
    listen 80;
    server_name app.example.com;

    # ACME-Challenge für Let's Encrypt
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Alles andere zu HTTPS
    location / {
        return 301 https://$server_name$request_uri;
    }
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

    # SSL-Zertifikate
    ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    # Document Root
    root /var/www/app.example.com/public;
    index index.php;

    # Logging
    access_log /var/log/nginx/app.example.com.access.log;
    error_log /var/log/nginx/app.example.com.error.log;

    # Rate Limiting
    limit_req zone=general burst=20 nodelay;
    limit_conn addr 50;

    # Strikte Login-Limits
    location /login {
        limit_req zone=login burst=3 nodelay;
        try_files $uri /index.php$is_args$args;
    }

    # API mit höherem Limit
    location /api {
        limit_req zone=api burst=200 nodelay;
        try_files $uri /index.php$is_args$args;
    }

    # PHP-Verarbeitung
    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.3-fpm-app.sock;

        # Timeouts
        fastcgi_read_timeout 60s;
        fastcgi_send_timeout 60s;
    }

    # Statische Assets cachen
    location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Sensible Dateien blockieren
    location ~ /\. {
        deny all;
    }

    location ~ /(composer\.(json|lock)|package\.json|\.env) {
        deny all;
    }

    # Standard-Route (Front Controller)
    location / {
        try_files $uri /index.php$is_args$args;
    }
}
# Aktivieren und testen
ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

Let’s Encrypt Zertifikate

# Certbot installieren
apt install -y certbot

# Verzeichnis für ACME-Challenge
mkdir -p /var/www/certbot

# Zertifikat holen
certbot certonly --webroot -w /var/www/certbot -d app.example.com

# Auto-Renewal testen
certbot renew --dry-run

Teil 3: PHP-FPM 8.3

Installation

# PHP Repository hinzufügen (für neueste Version)
apt install -y lsb-release
curl -sSL https://packages.sury.org/php/apt.gpg | gpg --dearmor -o /usr/share/keyrings/php-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/php-archive-keyring.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list

apt update

# PHP 8.3 mit Extensions
apt install -y \
    php8.3-fpm \
    php8.3-cli \
    php8.3-pgsql \
    php8.3-curl \
    php8.3-mbstring \
    php8.3-xml \
    php8.3-zip \
    php8.3-intl \
    php8.3-opcache \
    php8.3-redis \
    php8.3-gd \
    php8.3-bcmath

PHP.ini für Produktion

; /etc/php/8.3/fpm/conf.d/99-production.ini

; Fehler nicht anzeigen (nur loggen)
display_errors = Off
display_startup_errors = Off
log_errors = On
error_reporting = E_ALL

; Sicherheit
expose_php = Off
; allow_url_fopen: Viele Libs brauchen es (Composer, HTTP-Clients). Off wenn nicht nötig,
; On lassen wenn nötig - aber Remote-Reads/Uploads immer sauber validieren!
allow_url_fopen = On
allow_url_include = Off

; Limits
memory_limit = 256M
max_execution_time = 60
max_input_time = 60
post_max_size = 64M
upload_max_filesize = 64M
max_file_uploads = 20

; Session-Sicherheit
session.cookie_httponly = 1
session.cookie_secure = 1
session.use_strict_mode = 1
; Lax als Default - Strict nur wenn keine Cross-Site Flows (SSO, OAuth, Payment)
session.cookie_samesite = "Lax"

; Opcache
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.revalidate_freq = 0
opcache.save_comments = 1

; JIT: Optional - bringt bei typischen Webapps selten Performance-Gewinn
; opcache.jit = 1255
; opcache.jit_buffer_size = 128M

Hinweis: opcache.validate_timestamps = 0 bedeutet, dass PHP-Änderungen erst nach FPM-Restart aktiv werden. Für Development auf 1 setzen.

Pool pro Applikation

Jede App bekommt einen eigenen Pool mit eigenem User:

# App-User anlegen
useradd -r -s /usr/sbin/nologin -d /var/www/app.example.com app_example
; /etc/php/8.3/fpm/pool.d/app.example.com.conf

[app.example.com]
user = app_example
group = app_example

listen = /run/php/php8.3-fpm-app.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

; Process Manager
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 10
pm.max_requests = 500

; Slow-Log für Performance-Debugging
slowlog = /var/log/php/app.example.com-slow.log
request_slowlog_timeout = 5s

; Request-Timeout
request_terminate_timeout = 60s

; Environment (keine Secrets hier! Die gehören in .env oder Secret Store)
env[APP_ENV] = production

; System-ENV-Variablen nicht in PHP durchreichen
clear_env = yes

; Sicherheit: PHP-Admin-Werte können nicht überschrieben werden
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
; open_basedir an App anpassen (shared-Verzeichnisse einbeziehen)
php_admin_value[open_basedir] = /var/www/app.example.com:/var/www/app.example.com/shared:/tmp
php_admin_value[error_log] = /var/log/php/app.example.com-error.log
# Log-Verzeichnis
mkdir -p /var/log/php
chown www-data:www-data /var/log/php

# Standard-Pool deaktivieren
mv /etc/php/8.3/fpm/pool.d/www.conf /etc/php/8.3/fpm/pool.d/www.conf.disabled

# Neustart
systemctl restart php8.3-fpm

Composer installieren

# Composer installieren
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

# Als deploy-User verwenden
su - deploy -c "composer --version"

Teil 4: PostgreSQL 16

Installation

# PostgreSQL Repository
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/postgresql-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list

apt update
apt install -y postgresql-16 postgresql-contrib-16

Konfiguration für Performance

# /etc/postgresql/16/main/postgresql.conf

# Nur localhost, nicht auf public interface (auch wenn pg_hba dicht ist)
listen_addresses = 'localhost'
# Bei Socket-only: listen_addresses = ''

# Connections
max_connections = 100
superuser_reserved_connections = 3

# Memory (anpassen an Server-RAM!)
shared_buffers = 2GB              # 25% des RAM
effective_cache_size = 6GB        # 75% des RAM
work_mem = 16MB                   # Konservativ! Pro Operation, nicht global
maintenance_work_mem = 512MB      # Für VACUUM, CREATE INDEX, etc.
# Für Reports: SET LOCAL work_mem = '256MB';

# WAL
wal_buffers = 64MB
checkpoint_completion_target = 0.9
max_wal_size = 2GB

# Query Planner
random_page_cost = 1.1            # SSD
effective_io_concurrency = 200    # SSD

# Logging
log_destination = 'stderr'
logging_collector = on
log_directory = 'log'
log_filename = 'postgresql-%Y-%m-%d.log'
log_rotation_age = 1d
log_min_duration_statement = 1000  # Queries > 1s loggen
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a '

# Locale
lc_messages = 'en_US.UTF-8'

Authentifizierung

# /etc/postgresql/16/main/pg_hba.conf

# TYPE  DATABASE        USER            ADDRESS                 METHOD

# Local connections: postgres darf peer, alle anderen brauchen Passwort
local   all             postgres                                peer
local   all             all                                     scram-sha-256

# IPv4 local connections (nur mit Passwort)
host    all             all             127.0.0.1/32            scram-sha-256

# IPv4 from App-Server (falls separater DB-Server)
# host  all             all             10.100.0.0/24           scram-sha-256

# Replication (falls nötig)
# host  replication     replicator      10.100.0.0/24           scram-sha-256
# Neustart
systemctl restart postgresql

Datenbank und User anlegen

# Als postgres-User
sudo -u postgres psql

-- Applikations-User anlegen
CREATE USER app_user WITH PASSWORD 'sicheres_passwort_hier';

-- Datenbank anlegen
CREATE DATABASE app_db OWNER app_user;

-- Verbindung zur DB
\c app_db

-- Rechte einschränken (Principle of Least Privilege)
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT CREATE ON SCHEMA public TO app_user;

-- Extensions aktivieren (als Superuser)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";

\q

Backup-Script

#!/bin/bash
# /usr/local/bin/backup-postgresql.sh

BACKUP_DIR="/var/backups/postgresql"
DATE=$(date +%Y-%m-%d_%H%M)
RETENTION_DAYS=30

mkdir -p "$BACKUP_DIR"

# Alle Datenbanken sichern
for db in $(sudo -u postgres psql -At -c "SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres'"); do
    echo "Backing up $db..."
    sudo -u postgres pg_dump -Fc "$db" -f "$BACKUP_DIR/${db}_${DATE}.dump"
done

# Alte Backups löschen
find "$BACKUP_DIR" -name "*.dump" -mtime +$RETENTION_DAYS -delete

echo "Backup completed: $BACKUP_DIR"
chmod +x /usr/local/bin/backup-postgresql.sh

# Backup-Verzeichnis absichern
chmod 700 /var/backups/postgresql
chown postgres:postgres /var/backups/postgresql

# Crontab (täglich um 3 Uhr)
echo "0 3 * * * root /usr/local/bin/backup-postgresql.sh >> /var/log/postgresql-backup.log 2>&1" > /etc/cron.d/postgresql-backup

Backup ≠ Restore. Teste Restore regelmäßig! Ein Backup ohne Restore-Test ist Selbstbetrug. Monatlich in Staging testen.

WAL-Archiving für Point-in-Time Recovery (optional)

Für mission-critical Daten reicht pg_dump nicht - Sie brauchen kontinuierliche WAL-Archivierung:

# /etc/postgresql/16/main/postgresql.conf (Demo - siehe Hinweis!)
archive_mode = on
archive_command = 'cp %p /var/backups/postgresql/wal/%f'

Achtung: cp ist nur ein Minimalbeispiel! WAL-Archivierung braucht genug Speicherplatz, korrekte Rechte, Monitoring auf Fehler und idealerweise ein robustes Tool wie pgBackRest, Barman oder WAL-G. Diese übernehmen Kompression, Retention, S3-Upload und paralleles Restore.

Mit PITR können Sie auf jeden beliebigen Zeitpunkt zurückrollen - nicht nur auf das letzte Backup.


Teil 5: Monitoring

Node Exporter (Prometheus)

# Download (Version regelmäßig prüfen: https://github.com/prometheus/node_exporter/releases)
cd /tmp
curl -LO https://github.com/prometheus/node_exporter/releases/download/v1.7.0/node_exporter-1.7.0.linux-amd64.tar.gz
tar xzf node_exporter-1.7.0.linux-amd64.tar.gz
mv node_exporter-1.7.0.linux-amd64/node_exporter /usr/local/bin/

# User anlegen
useradd -r -s /usr/sbin/nologin node_exporter
# /etc/systemd/system/node_exporter.service

[Unit]
Description=Prometheus Node Exporter
After=network.target

[Service]
Type=simple
User=node_exporter
ExecStart=/usr/local/bin/node_exporter \
    --web.listen-address=127.0.0.1:9100 \
    --collector.systemd \
    --collector.processes

Restart=always

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable node_exporter
systemctl start node_exporter

Sicherheit: Node Exporter nur auf localhost binden. Zugriff von Prometheus über VPN (oder SSH-Tunnel mit AllowTcpForwarding local).

Nginx-Status für Monitoring

# /etc/nginx/sites-available/status.conf

server {
    listen 127.0.0.1:8080;
    server_name localhost;

    location /nginx_status {
        stub_status on;
        access_log off;
    }

    location /php_status {
        fastcgi_pass unix:/run/php/php8.3-fpm-app.sock;
        fastcgi_param SCRIPT_FILENAME $fastcgi_script_name;
        include fastcgi_params;
    }
}

PHP-FPM Status aktivieren (in Pool-Config):

; Am Ende von /etc/php/8.3/fpm/pool.d/app.example.com.conf

pm.status_path = /php_status
ping.path = /php_ping

Logwatch für tägliche Reports

apt install -y logwatch

# /etc/logwatch/conf/logwatch.conf
MailTo = admin@example.com
MailFrom = server@example.com
Range = yesterday
Detail = Med

Einfaches Alerting-Script

#!/bin/bash
# /usr/local/bin/health-check.sh

ALERT_EMAIL="admin@example.com"
HOSTNAME=$(hostname)

# Disk-Space Check (> 90% = Alert)
DISK_USAGE=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
if [ "$DISK_USAGE" -gt 90 ]; then
    echo "ALERT: Disk usage at ${DISK_USAGE}% on $HOSTNAME" | \
        mail -s "[$HOSTNAME] Disk Space Warning" "$ALERT_EMAIL"
fi

# Memory Check (< 100MB free = Alert)
FREE_MEM=$(free -m | awk '/^Mem:/{print $7}')
if [ "$FREE_MEM" -lt 100 ]; then
    echo "ALERT: Only ${FREE_MEM}MB memory available on $HOSTNAME" | \
        mail -s "[$HOSTNAME] Memory Warning" "$ALERT_EMAIL"
fi

# Service Checks
for service in nginx php8.3-fpm postgresql; do
    if ! systemctl is-active --quiet $service; then
        echo "ALERT: $service is not running on $HOSTNAME" | \
            mail -s "[$HOSTNAME] Service Down: $service" "$ALERT_EMAIL"
    fi
done

# SSL-Zertifikat Check (< 30 Tage = Alert)
for domain in app.example.com; do
    EXPIRY=$(echo | openssl s_client -servername $domain -connect $domain:443 2>/dev/null | \
        openssl x509 -noout -dates | grep notAfter | cut -d= -f2)
    EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
    NOW_EPOCH=$(date +%s)
    DAYS_LEFT=$(( ($EXPIRY_EPOCH - $NOW_EPOCH) / 86400 ))

    if [ "$DAYS_LEFT" -lt 30 ]; then
        echo "ALERT: SSL cert for $domain expires in $DAYS_LEFT days" | \
            mail -s "[$HOSTNAME] SSL Certificate Warning" "$ALERT_EMAIL"
    fi
done
chmod +x /usr/local/bin/health-check.sh

# Stündlich ausführen
echo "0 * * * * root /usr/local/bin/health-check.sh" > /etc/cron.d/health-check

Teil 6: Deployment-Workflow

Verzeichnisstruktur

/var/www/app.example.com/
├── current -> releases/20250115_143022    # Symlink zur aktiven Version
├── releases/
│   ├── 20250115_143022/
│   │   ├── public/                        # Document Root
│   │   ├── src/
│   │   ├── vendor/
│   │   └── ...
│   └── 20250114_091500/                   # Vorherige Version (Rollback)
├── shared/
│   ├── .env                               # Persistente Config
│   ├── storage/                           # Uploads, Cache, etc.
│   └── logs/
└── repo.git/                              # Bare Git Repository

Deployment-Script

#!/bin/bash
# /usr/local/bin/deploy.sh

set -e

APP_NAME="app.example.com"
DEPLOY_PATH="/var/www/$APP_NAME"
REPO="git@github.com:company/app.git"
BRANCH="${1:-main}"
KEEP_RELEASES=5

# Neue Release-ID
RELEASE=$(date +%Y%m%d_%H%M%S)
RELEASE_PATH="$DEPLOY_PATH/releases/$RELEASE"

echo "=== Deploying $APP_NAME ($BRANCH) ==="
echo "Release: $RELEASE"

# Clone
git clone --depth 1 --branch "$BRANCH" "$REPO" "$RELEASE_PATH"

# Sanity Check (Race-Condition vermeiden)
test -d "$RELEASE_PATH/public" || { echo "ERROR: public/ nicht gefunden!"; exit 1; }

# Shared Files/Directories verlinken
ln -nfs "$DEPLOY_PATH/shared/.env" "$RELEASE_PATH/.env"
ln -nfs "$DEPLOY_PATH/shared/storage" "$RELEASE_PATH/storage"

# Dependencies
cd "$RELEASE_PATH"
composer install --no-dev --optimize-autoloader --no-interaction

# Permissions (gezielt setzen)
chown -R app_example:app_example "$RELEASE_PATH"
find "$RELEASE_PATH" -type d -exec chmod 750 {} \;
find "$RELEASE_PATH" -type f -exec chmod 640 {} \;
# CLI-Tools müssen executable bleiben
chmod 750 "$RELEASE_PATH/bin/"* 2>/dev/null || true
chmod 750 "$RELEASE_PATH/vendor/bin/"* 2>/dev/null || true
chmod -R 770 "$RELEASE_PATH/storage"

# Migrations (falls nötig)
# sudo -u app_example php artisan migrate --force

# Symlink umschalten (Atomic!)
ln -nfs "$RELEASE_PATH" "$DEPLOY_PATH/current"

# PHP-FPM neu laden (Opcache leeren)
systemctl reload php8.3-fpm

# Alte Releases aufräumen
cd "$DEPLOY_PATH/releases"
ls -1dt */ | tail -n +$((KEEP_RELEASES + 1)) | xargs -r rm -rf

echo "=== Deployment complete ==="

Rollback

#!/bin/bash
# /usr/local/bin/rollback.sh

APP_NAME="app.example.com"
DEPLOY_PATH="/var/www/$APP_NAME"

# Aktuelle und vorherige Version finden
CURRENT=$(readlink "$DEPLOY_PATH/current" | xargs basename)
PREVIOUS=$(ls -1t "$DEPLOY_PATH/releases" | grep -v "^$CURRENT$" | head -1)

if [ -z "$PREVIOUS" ]; then
    echo "Keine vorherige Version zum Rollback gefunden!"
    exit 1
fi

echo "Rolling back from $CURRENT to $PREVIOUS"

# Symlink umschalten
ln -nfs "$DEPLOY_PATH/releases/$PREVIOUS" "$DEPLOY_PATH/current"

# PHP-FPM neu laden
systemctl reload php8.3-fpm

echo "Rollback complete!"

Teil 7: Security Checkliste

Vor dem Go-Live prüfen

┌─────────────────────────────────────────────────────────────────────────────┐
│                    PRODUCTION SECURITY CHECKLIST                             │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  SSH                                                                         │
│  ☐ Root-Login deaktiviert                                                   │
│  ☐ Password-Auth deaktiviert                                                │
│  ☐ SSH-Keys mit Passphrase                                                  │
│  ☐ Fail2Ban aktiv                                                           │
│                                                                              │
│  Firewall                                                                    │
│  ☐ Default Deny                                                             │
│  ☐ Nur nötige Ports offen (22, 80, 443)                                    │
│  ☐ Rate Limiting für SSH                                                    │
│                                                                              │
│  Nginx                                                                       │
│  ☐ TLS 1.2+ only                                                            │
│  ☐ Security Headers aktiv                                                   │
│  ☐ Server-Token versteckt                                                   │
│  ☐ Rate Limiting für Login/API                                              │
│  ☐ Sensible Dateien blockiert (.env, .git)                                 │
│                                                                              │
│  PHP                                                                         │
│  ☐ expose_php = Off                                                         │
│  ☐ display_errors = Off                                                     │
│  ☐ open_basedir gesetzt                                                     │
│  ☐ disable_functions gesetzt                                                │
│  ☐ Separate User pro App                                                    │
│                                                                              │
│  PostgreSQL                                                                  │
│  ☐ Nicht auf public interface                                               │
│  ☐ scram-sha-256 Auth                                                       │
│  ☐ App-User mit minimalen Rechten                                           │
│  ☐ Backup-Script + Cron                                                     │
│                                                                              │
│  System                                                                      │
│  ☐ Automatische Security Updates                                            │
│  ☐ Logwatch/Monitoring aktiv                                                │
│  ☐ SSL-Zertifikate Auto-Renewal                                             │
│  ☐ Deployment-Script getestet                                               │
│  ☐ Rollback-Prozedur dokumentiert                                           │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Fazit

Ein production-ready Debian-Server erfordert mehr als nur apt install nginx php postgresql. Die wichtigsten Punkte:

  1. Least Privilege: Separate User für jeden Dienst und jede App
  2. Defense in Depth: Firewall + Rate Limiting + Fail2Ban + Application Security
  3. Monitoring: Probleme erkennen, bevor Kunden sie melden
  4. Automation: Deployment-Scripts, Auto-Updates, Backup-Crons
  5. Dokumentation: Checklisten für Setup und Go-Live

Der initiale Aufwand lohnt sich: Ein gut gehärteter Server macht nachts keine Probleme - und wenn doch, wissen Sie schnell, wo Sie suchen müssen.


Weiterführende Ressourcen

Carola Schulte

Über Carola Schulte

Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.

Projekt im Kopf?

Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.

Kostenloses Erstgespräch