Debian Server Setup: Von der Installation bis zum Production-Ready
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 = 0bedeutet, dass PHP-Änderungen erst nach FPM-Restart aktiv werden. Für Development auf1setzen.
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:
cpist 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:
- Least Privilege: Separate User für jeden Dienst und jede App
- Defense in Depth: Firewall + Rate Limiting + Fail2Ban + Application Security
- Monitoring: Probleme erkennen, bevor Kunden sie melden
- Automation: Deployment-Scripts, Auto-Updates, Backup-Crons
- 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
Ü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