[ L4D2 Stats — Install Guide ]

Step-by-step install guide for adding persistent player stats and a web UI to a Debian-based L4D2 dedicated server. The finished setup records campaign completions, kills, friendly-fire, skill plays, and more — viewable on a public website behind HTTPS. This is the recipe powering l4d2.magikh0e.pl; you can replicate it on any VPS with a domain pointed at it.

Prerequisite: a working L4D2 dedicated server with SourceMod and MetaMod:Source already installed. If you don't have one yet, start with the L4D2 Server Install Guide — do that first, then come back here.

[ Overview ]

Components

  Plugin          Jackz's L4D2 Stats Recorder (SourcePawn, compiled from source)
  Database        MariaDB (MySQL also works)
  Web UI          Jackz's Astro stats website
  Reverse proxy   nginx with Let's Encrypt HTTPS

Assumptions

  - Debian 12+ or Ubuntu 22.04+ server
  - L4D2 dedicated server installed (LinuxGSM or similar)
  - SourceMod 1.11+ and MetaMod:Source installed
  - A domain name with an A record pointing at your VPS
  - Non-root user with sudo (referred to as $USER below)

Estimated time: 1–2 hours start to finish, more if you hit the schema/plugin drift gotcha — see §14.


[ 1. Install MariaDB ]

sudo apt update
sudo apt install -y mariadb-server
sudo systemctl enable --now mariadb
sudo mysql_secure_installation     # answer "Y" to all

Lock root to unix_socket auth (no password attack surface):

sudo mysql
ALTER USER 'root'@'localhost' IDENTIFIED VIA unix_socket;
FLUSH PRIVILEGES;
EXIT;

[ 2. Create database and user ]

sudo mysql
CREATE DATABASE stats CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'l4d2stats'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD_HERE';
GRANT ALL PRIVILEGES ON stats.* TO 'l4d2stats'@'localhost';
FLUSH PRIVILEGES;
EXIT;

Replace STRONG_PASSWORD_HERE with openssl rand -base64 24.


[ 3. Install MariaDB i386 client library ]

L4D2 dedicated server is 32-bit (i386). SourceMod's MySQL extension needs the matching 32-bit client lib.

sudo dpkg --add-architecture i386
sudo apt update
sudo apt install -y libmariadb3:i386

# Symlink so SourceMod can find it
sudo ln -sf /usr/lib/i386-linux-gnu/libmariadb.so.3 /usr/lib/i386-linux-gnu/libmysqlclient.so.18

Verify SourceMod sees it:

./l4d2server send "sm exts list"
sleep 2
tail -20 ~/log/console/l4d2server-console.log | grep -i mysql

Should show dbi.mysql.ext.so loaded without errors.


[ 4. Configure SourceMod's database connection ]

Edit ~/serverfiles/left4dead2/addons/sourcemod/configs/databases.cfg and add:

"Databases"
{
    "default"
    {
        "driver"            "mysql"
        "host"              "127.0.0.1"
        "database"          "stats"
        "user"              "l4d2stats"
        "pass"              "STRONG_PASSWORD_HERE"
        "port"              "3306"
    }
    "stats"
    {
        "driver"            "mysql"
        "host"              "127.0.0.1"
        "database"          "stats"
        "user"              "l4d2stats"
        "pass"              "STRONG_PASSWORD_HERE"
        "port"              "3306"
    }
}

[ 5. Get the stats website repo (contains SQL schemas) ]

cd ~
git clone https://github.com/Jackzmc/l4d2-stats-website.git

Import the schema:

mysql -u l4d2stats -p stats < ~/l4d2-stats-website/sql/stats_database.sql

Then apply v3 migration if present:

mysql -u l4d2stats -p stats < ~/l4d2-stats-website/sql/v3-migration.sql 2>/dev/null

Verify:

mysql -u l4d2stats -p stats -e "SHOW TABLES;"

Should list ~12 tables (stats_users, stats_games, stats_sessions, stats_map_info, etc.).


[ 6. Compile the stats plugin from source ]

The precompiled binary in releases is often v2 — won't work with v3 schema. Compile fresh:

cd /tmp
git clone --depth 1 https://github.com/Jackzmc/l4d2-stats-plugin.git jackz-stats
cd jackz-stats

Copy SourcePawn includes to your scripting directory:

cp scripting/include/*.inc ~/serverfiles/left4dead2/addons/sourcemod/scripting/include/
cp -r scripting/stats ~/serverfiles/left4dead2/addons/sourcemod/scripting/
cp scripting/l4d2_stats_recorder.sp ~/serverfiles/left4dead2/addons/sourcemod/scripting/

Compile:

cd ~/serverfiles/left4dead2/addons/sourcemod/scripting
./spcomp l4d2_stats_recorder.sp

You should get l4d2_stats_recorder.smx with no errors. Move to plugins:

mv l4d2_stats_recorder.smx ../plugins/

[ 7. Install dependencies ]

The plugin requires L4D Info Editor (by SilverShot). Download from forums.alliedmods.net/showthread.php?t=310586.

Extract and copy addons/sourcemod/* over your install:

unzip /tmp/l4d_info_editor.zip -d /tmp/info-editor
cp -r /tmp/info-editor/addons/sourcemod/* ~/serverfiles/left4dead2/addons/sourcemod/

Also ensure Left 4 DHooks Direct and skill_detect are installed (most modern L4D2 servers already have them).


[ 8. Load and verify ]

./l4d2server send "sm plugins load l4d2_stats_recorder"
sleep 3
./l4d2server send "sm plugins info l4d2_stats_recorder.smx"
sleep 2
tail -30 ~/log/console/l4d2server-console.log | grep -iE "stats_recorder|connected to database|error"

Should see Connected to database stats and no errors.

Important: The plugin only writes stats_games rows on finale_win. To test, play a finale map (e.g., c2m5_concert) to the rescue. Then:

mysql -u l4d2stats -p stats -e "SELECT * FROM stats_games;"
mysql -u l4d2stats -p stats -e "SELECT * FROM stats_users;"

[ 9. Install Node.js 22 and pnpm (for the website) ]

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
sudo apt install -y nodejs
sudo npm install -g pnpm

[ 10. Set up the Astro website ]

cd ~/l4d2-stats-website/website

# Configure pnpm to run install scripts (needed for skia-canvas native module)
cat > .npmrc <<EOF
enable-pre-post-scripts=true
node-linker=hoisted
EOF

# Install deps
pnpm install --production=false

# Configure
cat > .env.local <<EOF
DATABASE_URL=mysql://l4d2stats:STRONG_PASSWORD_HERE@localhost/stats
PUBLIC_SITE_URL=https://YOUR_DOMAIN_HERE
PUBLIC_SITE_NAME=My L4D2 Stats
HOST=0.0.0.0
PORT=4321
EOF

# Build
pnpm build

If pnpm build fails complaining about skia.node missing:

cd ~/l4d2-stats-website/website/node_modules/.pnpm/skia-canvas@*/node_modules/skia-canvas
node lib/prebuild.mjs download
cd ~/l4d2-stats-website/website
pnpm build

[ 11. systemd service for the website ]

sudo tee /etc/systemd/system/l4d2-stats-web.service > /dev/null <<EOF
[Unit]
Description=L4D2 Stats Astro Web UI
After=network.target mariadb.service

[Service]
Type=simple
User=$USER
WorkingDirectory=/home/$USER/l4d2-stats-website/website
EnvironmentFile=/home/$USER/l4d2-stats-website/website/.env.local
ExecStart=/usr/bin/node /home/$USER/l4d2-stats-website/website/dist/server/entry.mjs
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now l4d2-stats-web
sudo systemctl status l4d2-stats-web --no-pager

Test: curl http://localhost:4321/ should return HTML.


[ 12. nginx reverse proxy with HTTPS ]

sudo apt install -y nginx certbot python3-certbot-nginx

Create /etc/nginx/sites-available/l4d2-stats:

server {
    listen 80;
    listen [::]:80;
    server_name YOUR_DOMAIN_HERE;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name YOUR_DOMAIN_HERE;

    location / {
        proxy_pass http://127.0.0.1:4321;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Enable and get the cert:

sudo ln -s /etc/nginx/sites-available/l4d2-stats /etc/nginx/sites-enabled/
sudo certbot --nginx -d YOUR_DOMAIN_HERE
sudo systemctl reload nginx

Certbot auto-modifies the config with cert paths and sets up auto-renewal.


[ 13. Point the in-game URL at your site ]

Edit ~/serverfiles/left4dead2/cfg/sourcemod/l4d2_stats_recorder.cfg:

l4d2_stats_url "https://YOUR_DOMAIN_HERE/"

Apply live:

./l4d2server send 'l4d2_stats_url "https://YOUR_DOMAIN_HERE/"'

[ 14. Known gotchas ]

The plugin only commits on finale_win. Partial campaigns don't get recorded as games. The site requires at least one completed finale to render the Summary page.

stats_map_info.flags must be 1 for the Summary page to recognize a map as official:

UPDATE stats_map_info SET flags = 1 WHERE flags = 0;

Run this any time a new finale map gets added to the table.

Schema/plugin lockstep: If you ever update either Jackz's plugin or website via git pull, update both together. Version drift causes hard-to-debug column mismatch errors.

LinuxGSM uses l4d2server.cfg, not server.cfg — they're loaded at different times. Don't confuse them.


[ 15. Hardening (recommended) ]

# Firewall - close everything except essentials
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 27015/udp comment 'L4D2 game'
sudo ufw allow 27020/udp comment 'SourceTV'
sudo ufw enable

# fail2ban for SSH brute-force protection
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban

# Auto-apply security updates
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Daily backup cron (place script in ~/backup.sh, chmod +x, then crontab -e):

0 4 * * * /home/$USER/backup.sh

First, drop the DB password into ~/.my.cnf so mysqldump can pick it up without exposing it on the command line (which would leak to ps while the dump runs):

cat > ~/.my.cnf <<EOF
[client]
user=l4d2stats
password=STRONG_PASSWORD_HERE
EOF
chmod 600 ~/.my.cnf

Then the backup script itself (~/backup.sh):

#!/bin/bash
# Daily L4D2 stats backup — DB dump + critical configs, 30-day rotation.
# Invoked by cron at 04:00 every day.

set -euo pipefail

BACKUP_DIR="$HOME/backups"
DATE=$(date +%Y-%m-%d)
RETENTION_DAYS=30

mkdir -p "$BACKUP_DIR"

# 1. Database dump (single transaction, gzipped). Credentials read from
#    ~/.my.cnf — keeps the password out of process listings.
mysqldump --single-transaction --quick --routines stats \
    | gzip -9 > "$BACKUP_DIR/stats-$DATE.sql.gz"

# 2. Tarball of the configs that take real effort to rebuild. The
#    "|| true" swallows missing-file errors so an absent .env.local
#    (e.g., reinstall in progress) doesn't kill the rest of the backup.
tar czf "$BACKUP_DIR/configs-$DATE.tar.gz" \
    "$HOME/serverfiles/left4dead2/addons/sourcemod/configs/databases.cfg" \
    "$HOME/serverfiles/left4dead2/cfg/sourcemod/l4d2_stats_recorder.cfg" \
    "$HOME/l4d2-stats-website/website/.env.local" \
    /etc/nginx/sites-available/l4d2-stats \
    2>/dev/null || true

# 3. Prune anything older than the retention window.
find "$BACKUP_DIR" -name 'stats-*.sql.gz'   -mtime +$RETENTION_DAYS -delete
find "$BACKUP_DIR" -name 'configs-*.tar.gz' -mtime +$RETENTION_DAYS -delete

# 4. One-line success log — cheap audit trail.
echo "$(date -Iseconds) backup OK ($(du -sh $BACKUP_DIR | cut -f1) on disk)" \
    >> "$BACKUP_DIR/backup.log"

Set executable and test once interactively before relying on cron:

chmod +x ~/backup.sh
~/backup.sh
ls -la ~/backups/

You should see one stats-YYYY-MM-DD.sql.gz and one configs-YYYY-MM-DD.tar.gz.

Restore drill: practice the restore at least once before you need it.

# DB restore
gunzip -c ~/backups/stats-YYYY-MM-DD.sql.gz | mysql stats

# Configs restore (extract back into place)
tar xzf ~/backups/configs-YYYY-MM-DD.tar.gz -C /

Offsite copy: local backups don't help if the VPS itself dies. After the script lands cleanly, add a second cron line that rsyncs ~/backups/ to a remote box, B2 bucket, or whatever you have.


[ Summary ]

After all steps:

  - Plugin records games to MariaDB on each finale_win
  - Website at https://YOUR_DOMAIN_HERE displays leaderboards, summary,
    maps, game history
  - In-game chat shows the stats URL at finale completion
  - All traffic is HTTPS, ports are locked down, backups run nightly

Total time: 1–2 hours start to finish, more if you hit the schema/plugin drift gotcha.

[ Raw markdown ]

This guide is also available as a single markdown file (handy for piping through your own renderer or grepping):

📄 l4d2-stats-install-guide.md

[ See Also ]