new_server_setup.md

Server Setup

Check Connectivity!

Make sure the server IP is not blocked: apt update -y && add-apt-repository -y ppa:ondrej/php

User Management

  1. Local: Add new server information into ~/.ssh/config setting the user as root.
  2. Log in as root via SSH (ssh SERVER)
adduser ifor
usermod -a -G sudo ifor
usermod -a -G www-data ifor
mkdir /home/ifor/.ssh
chown -R ifor:ifor /home/ifor/
  1. Local: copy the local public key to the server: cat $HOME/.ssh/id_rsa.pub | ssh SERVER "cat >> /home/ifor/.ssh/authorized_keys"
  2. Local: Set server config user to the new user (~/.ssh/config)

GIT

  1. ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_deploy -C "Deploy key for karst-climber"
  2. chmod 600 ~/.ssh/id_rsa_deploy
  3. chmod 700 ~/.ssh
  4. vi ~/.ssh/config and insert:
Host github
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_rsa_deploy
    IdentitiesOnly yes
  1. eval "$(ssh-agent -s)"
  2. ssh-add ~/.ssh/id_rsa_deploy
  3. Add deploy public SSH key to Github (https://github.com/settings/ssh/new)
  4. Test Git: ssh -T git@github.com

Personalisation

  1. git clone https://github.com/iforwms/dotfiles.git $HOME/.dotfiles
  2. Clone .dotfiles and create symlinks for config files:
    1. ln -s /home/ifor/.dotfiles/tmux/.tmux.conf .
    2. ln -s /home/ifor/.dotfiles/.vim .
    3. ln -s /home/ifor/.dotfiles/.vimrc .
  3. Secure SSH: /home/ifor/.dotfiles/scripts/server/secure_ssh
  4. sudo apt update
  5. Install ZSH:
    1. sudo apt install -y zsh
    2. sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
  6. Remove rm -f $HOME/.zshrc and replace with symlink ln -s /home/ifor/.dotfiles/zsh/.zshrc .
  7. Install zsh plugins:
    1. rm -rf $HOME/.dotfiles/zsh/plugins/zsh-autosuggestions && rm -rf $HOME/.dotfiles/zsh/plugins/zsh-syntax-highlighting && git clone --depth 1 https://github.com/zsh-users/zsh-autosuggestions $HOME/.dotfiles/zsh/plugins/zsh-autosuggestions && git clone --depth 1 https://github.com/zsh-users/zsh-syntax-highlighting.git $HOME/.dotfiles/zsh/plugins/zsh-syntax-highlighting && rm -rf $HOME/.dotfiles/zsh/plugins/zsh-vi-man && git clone --depth 1 https://github.com/TunaCuma/zsh-vi-man ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-vi-man
  8. Fix permissions for .dotfiles/zsh and all subdirectories/files:
    1. chmod -R go-w ~/.dotfiles/zsh
    2. find ~/.dotfiles/zsh -type d -exec chmod 755 {} \;
    3. find ~/.dotfiles/zsh -type f -exec chmod 644 {} \;
  9. Set default shell to zsh - chsh -s $(which zsh)
  10. Download vi colour scheme:
    1. wget https://raw.githubusercontent.com/joshdick/onedark.vim/master/colors/onedark.vim -O $HOME/.vim/colors/onedark.vim
    2. wget https://raw.githubusercontent.com/joshdick/onedark.vim/master/autoload/onedark.vim -O $HOME/.vim/autoload/onedark.vim
  11. Set correct timezone
    1. timedatectl list-timezones
    2. sudo timedatectl set-timezone Asia/Shanghai
  12. Install compiler sudo apt install -y libmaxminddb0 libmaxminddb-dev mmdb-bin build-essential libpcre3-dev zlib1g-dev libssl-dev libxml2-dev libxslt-dev libgd-dev
  13. Install useful programs sudo apt install -y wget tree htop tmux ncdu git clamav sqlite3
  14. Set hostname: sudo hostnamectl set-hostname new-hostname

node/npm

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash nvm ls-remote nvm install v24.13.0 node -v npm -v

LEMP Stack

Nginx

  1. sudo apt install -y nginx
  2. Make sure nginx has permissions to read the folder (and containing folders of the code)
  3. Fix permissions for conf.d to root:
    1. sudo chown -R root:root /etc/nginx
    2. sudo chmod -R 755 /etc/nginx
  4. Optimize config: sudo vi /etc/nginx/nginx.conf:
    1. Uncomment # server_tokens off; and update the gzip conf and headers as follows:
  add_header X-Frame-Options "SAMEORIGIN" always;
  add_header X-Content-Type-Options "nosniff" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
  add_header X-XSS-Protection "1; mode=block" always;
  
  client_max_body_size 200M;
  
  ssl_session_cache shared:SSL:10m;
  ssl_session_timeout 10m;
  
gzip on;
gzip_disable "msie6";

gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
  application/atom+xml
  application/geo+json
  application/javascript
  application/x-javascript
  application/json
  application/ld+json
  application/manifest+json
  application/rdf+xml
  application/rss+xml
  application/xhtml+xml
  application/xml
  font/eot
  font/otf
  font/ttf
  image/svg+xml
  text/css
  text/javascript
  text/plain
  text/xml;
  1. sudo systemctl restart nginx
  2. sudo systemctl stop apache2
  3. sudo systemctl disable apache2
  4. sudo systemctl mask apache2

Install FancyIndex (optional)

1. `nginx -v`
2. Create temp directory and `cd` inside
3. `wget https://nginx.org/download/nginx-1.18.0.tar.gz` (or version returned by previous command)
4. `git clone https://github.com/aperezdc/ngx-fancyindex.git`
5. Get GeoIP module: `wget https://github.com/leev/ngx_http_geoip2_module/archive/refs/tags/3.3.tar.gz`
6. `tar xvfz 3.3.tar.gz`
7. `nginx -V` to get the exact arguments `nginx` was installed with
8. `cd nginx-1.18.0`
9. `./configure [... args from nginx -V] --add-dynamic-module=../ngx-fancyindex` (replacing the GeoIP location with the newly downloaded one - `--add-dynamic-module=../ngx_http_geoip2_module-3.3 --with-stream`
10. Once `./configure` completes: `make modules` which will generate `*.so` files in the `objs` directory
11. `sudo cp -vi ./objs/ngx_http_fancyindex_module.so ./objs/ngx_http_geoip2_module.so ./objs/ngx_stream_geoip2_module.so /usr/share/nginx/modules`
14. Add  `load_module /usr/share/nginx/modules/ngx_http_fancyindex_module.so;` to `/etc/nginx/nginx.conf` (after L4. `include /etc/nginx/modules-enabled/*.conf;`).
15. `sudo nginx -t` and `sudo service nginx restart`
16. Finally use `fancyindex` directive instead of `autoindex`
17. For sites that use fancy index, download the theme and copy light/dark theme to the root folder: `git clone --depth 1 git@github.com:Naereen/Nginx-Fancyindex-Theme`
18. Set `location` directive as follows:
fancyindex on;
fancyindex_localtime on;
fancyindex_exact_size off;
# Specify the path to the header.html and foother.html files, that are server-wise,
# ie served from root of the website. Remove the leading '/' otherwise.
fancyindex_header "/Nginx-Fancyindex-Theme-light/header.html";
fancyindex_footer "/Nginx-Fancyindex-Theme-light/footer.html";
# Ignored files will not show up in the directory listing, but will still be public.
fancyindex_ignore "examplefile.html";
# Making sure folder where these files are do not show up in the listing.
fancyindex_ignore "Nginx-Fancyindex-Theme-light";

MySQL

For MySQL to run, the droplet must have at least 1GB of RAM.

  1. sudo apt install -y mysql-server if it fails:
    1. sudo apt purge 'mysql*'
    2. sudo apt autoremove
    3. sudo apt autoclean
  2. sudo mysql_secure_installation
    1. Validate Password → yes (1 - medium)
    2. Remove anonymous users → yes
    3. Disallow root login remotely → yes
    4. Remove test DB → yes
    5. Reload privilege tables → yes
  3. sudo mysql -u root
  4. ALTER USER 'root'@'localhost' IDENTIFIED BY 'NEW_PASS'; flush privileges; exit;
  5. sudo mysql -u root -p <new-password>
  6. Set timezone
    1. mysql --help | grep my.cnf
    2. Load timezone info: sudo mysql_tzinfo_to_sql /usr/share/zoneinfo | sudo mysql -u root -p mysql
    3. sudo vi /etc/mysql/mysql.conf.d/mysqld.cnf
    4. Add default-time-zone='+08:00' or default-time-zone='Europe/Stockholm' under [mysqld] section
    5. sudo systemctl restart mysql
  7. sudo mkdir -p /var/run/mysqld
  8. sudo chown mysql:mysql /var/run/mysqld
  9. If lost root access, use the following:

PHP

  1. sudo apt install -y php php-{fpm,mysql,xml,common,cli,mbstring,curl,zip,json,bcmath,tokenizer,gd,imagick,intl}
  2. Update to latest php:
    1. sudo apt install -y curl gpg gnupg2 software-properties-common ca-certificates apt-transport-https lsb-release
    2. sudo add-apt-repository -y ppa:ondrej/php
    3. sudo add-apt-repository -y ppa:ondrej/nginx
    4. sudo apt update -y
    5. sudo apt -y install php8.4 php-{fpm,mysql,xml,common,cli,mbstring,curl,zip,bcmath,tokenizer,gd,imagick,intl,sqlite3}
    6. If can't connect to ppa:ondrej:
dig +short launchpad.net A

# Example result:
# 185.125.189.223

# Temporarily force IPv4
sudo bash -c 'echo "185.125.189.223 launchpad.net" >> /etc/hosts'

# Then run
sudo add-apt-repository -y ppa:ondrej/php
  1. sudo update-alternatives --config php
  2. sudo vi /etc/php/8.4/fpm/pool.d/www.conf:
/etc/php/8.4/fpm/pool.d/<site>.conf

[laravel]
user = www-data
group = www-data

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

pm = dynamic
pm.max_children = 15
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 7
pm.max_requests = 500

php_admin_value[memory_limit] = 256M
php_admin_value[error_log] = /var/log/php8.4-fpm-laravel2.log
php_admin_flag[log_errors] = on

[wordpress]
user = www-data
group = www-data

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

pm = dynamic
pm.max_children = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
pm.max_requests = 500

php_admin_value[memory_limit] = 256M
php_admin_value[error_log] = /var/log/php8.4-fpm-wordpress.log
php_admin_flag[log_errors] = on
  1. Install composer:
    1. php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
    2. php -r "if (hash_file('sha384', 'composer-setup.php') === 'e21205b207c3ff031906575712edab6f13eb0b361f2085f1f1237b7126d785e826a450292b6cfd1d64d92e6563bbde02') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
    3. php composer-setup.php
    4. php -r "unlink('composer-setup.php');"
    5. sudo mv composer.phar /usr/local/bin/composer
    6. Optimise FPM INI settings
      1. sudo vi /etc/php/<VERSION>/fpm/php.ini
        1. date.timezone = Asia/Shanghai
        2. upload_max_filesize = 200M
        3. post_max_size = 200M
        4. memory_limit = 256M
        5. disable_functions = exec,passthru,shell_exec,system
        6. expose_php = Off
      2. sudo systemctl restart php8.4-fpm

Firewall

fail2ban & ufw

sudo apt install -y fail2ban ufw sudo systemctl start fail2ban sudo systemctl enable fail2ban sudo systemctl status fail2ban

sudo tee /etc/fail2ban/jail.d/custom.conf > /dev/null <<'EOF'
[DEFAULT]
bantime = 1d
findtime = 1d
ignoreip = 127.0.0.1/8 192.168.0.0/16
maxretry = 1

banaction = ufw
banaction_allports = ufw
EOF
sudo tee /etc/fail2ban/filter.d/ufw.conf > /dev/null <<'EOF'
[Definition]
failregex = [UFW BLOCK].+SRC=<HOST> DST
ignoreregex =
EOF

sudo systemctl restart fail2ban sudo fail2ban-client status sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow OpenSSH sudo ufw allow "Nginx Full" sudo ufw allow out http sudo ufw allow out https sudo ufw allow out 465 sudo ufw allow out 587 sudo ufw allow out 22 sudo ufw allow out 53 sudo ufw enable sudo ufw status verbose


sudo ls -alt /etc/fail2ban/filter.d/nginx*

sudo tee /etc/fail2ban/filter.d/nginx-sslerror.conf > /dev/null <<'EOF'
# Fail2Ban filter for Nginx SSL handshake failures

[Definition]
failregex = \[crit\] \d+#\d+: \*\d+ SSL_do_handshake\(\) failed \(SSL: error:1417D18C:SSL routines:tls_process_client_hello:version too low\) while SSL handshaking, client: <HOST>, server: \S*\s*$
ignoreregex =
EOF
sudo tee /etc/fail2ban/filter.d/nginx-4xx.conf > /dev/null <<'EOF'
# Fail2Ban filter for Nginx 4xx errors

[Definition]
failregex = ^<HOST>.*"(GET|POST).*" (404|444|403|400) .*
ignoreregex = .*(robots.txt|favicon.ico|jpg|png)
EOF
sudo tee /etc/fail2ban/filter.d/nginx-forbidden.conf > /dev/null <<'EOF'
# Fail2Ban filter for directory index forbidden errors

[Definition]
failregex = directory index of .+ is forbidden, client: <HOST>, server: .+
ignoreregex =
EOF
sudo tee /etc/fail2ban/filter.d/nginx-wp-login.conf > /dev/null <<'EOF'
# Fail2Ban filter for WordPress wp-login.php attempts

[Definition]
failregex = ^<HOST>.*"(POST|GET) /wp-login.php
ignoreregex =
EOF
sudo tee /etc/fail2ban/filter.d/nginx-botsearch.conf > /dev/null <<'EOF'
# Fail2Ban filter to match web requests for selected URLs that don't exist

[INCLUDES]
before = botsearch-common.conf

[Definition]
# Match 404 errors for selected URLs
failregex = ^<HOST> - \S+ \[\] "(GET|POST|HEAD) /<block> \S+" 404 .*$

# Match Nginx error log "No such file or directory" messages
failregex += ^\[error\] \d+#\d+: \*\d+ (\S+ )?"\S+" (failed|is not found) \(2: No such file or directory\), client: <HOST>, server: \S*, request: "(GET|POST|HEAD) /<block> \S+",.*

ignoreregex =

# Optional: can define datepattern if needed, but default works
# datepattern = ^%%Y-%%m-%%d %%H:%%M:%%S
EOF
sudo tee /etc/fail2ban/filter.d/nginx-http-auth.conf > /dev/null <<'EOF'
# Fail2Ban filter for Nginx HTTP auth failures

[Definition]
failregex = ^\[error\] \d+#\d+: \*\d+ user "(?:[^"]+|.*?)":? (password mismatch|was not found in "[^"]*"), client: <HOST>, server: \S*, request: "\S+ \S+ HTTP/\d\.\d", host: "\S+"(?:, referrer: "\S+")?\s*$
ignoreregex =
EOF
sudo tee /etc/fail2ban/filter.d/nginx-limit-req.conf > /dev/null <<'EOF'
# Fail2Ban filter for Nginx limit_req module

[Definition]
# Zones defined in nginx configuration (comma-separated)
ngx_limit_req_zones = [^"]+

# Match Nginx limit_req exceeded messages
failregex = ^\s*\[[a-z]+\] \d+#\d+: \*\d+ limiting requests, excess: [\d\.]+ by zone "(?:%(ngx_limit_req_zones)s)", client: <HOST>,
ignoreregex =
EOF
sudo tee /etc/fail2ban/filter.d/nginx-wp-xmlrpc.conf > /dev/null <<'EOF'
[Definition]
failregex = ^<HOST>.*"(POST|GET) /xmlrpc.php
ignoreregex =
EOF
sudo tee -a /etc/fail2ban/jail.d/custom.conf > /dev/null <<'EOF'
[sshd]
enabled = true

[nginx-4xx]
enabled  = true
port     = http,https
filter   = nginx-4xx
logpath  = %(nginx_access_log)s
maxretry = 15
findtime = 10m
bantime  = 1h

[nginx-http-auth]
enabled = true
port     = http,https
filter   = nginx-http-auth
logpath  = %(nginx_error_log)s

[nginx-botsearch]
enabled = true
port     = http,https
filter   = nginx-botsearch
logpath  = %(nginx_access_log)s

[nginx-forbidden]
enabled = true
port    = http,https
filter  = nginx-forbidden
logpath = %(nginx_error_log)s

[nginx-sslerror]
enabled = true
port    = http,https
filter  = nginx-sslerror
logpath = %(nginx_error_log)s

[nginx-wp-login]
enabled   = true
filter    = nginx-wp-login
port      = http,https
logpath   = %(nginx_access_log)s
maxretry  = 5
findtime  = 10m
bantime   = 12h

[nginx-wp-xmlrpc]
enabled   = true
filter    = nginx-wp-xmlrpc
port      = http,https
logpath   = %(nginx_access_log)s
maxretry  = 3
findtime  = 10m
bantime   = 24h

[ufw]
enabled = true
filter  = ufw
logpath = /var/log/ufw.log
EOF
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[DEFAULT]
banaction = iptables-multiport
bantime   = 1h
findtime  = 10m
maxretry  = 5
backend   = systemd

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

sudo systemctl restart fail2ban sudo fail2ban-client status

Unbanning Specific IP

fail2ban-client set ufw unbanip 192.0.2.2

Install supervisor

sudo apt-get install -y supervisor

Install Anti-virus

  1. sudo apt install -y clamav
  2. sudo freshclam
  3. clamscan -r /file-to-scan

Configure Mailer

  1. sudo apt install -y aide msmtp msmtp-mta bsd-mailx - Do not install AppArmor
  2. vi ~/.msmtprc - Use an app-specific password from Zoho, not your regular password.
# Default settings
defaults
auth           on
tls            on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile        ~/.msmtp.log

# Zoho SMTP account
account        zoho
host           smtp.zoho.com
port           587
from           your_email@yourdomain.com
user           your_email@yourdomain.com
password       YOUR_ZOHO_APP_PASSWORD

# Make this account default
account default : zoho
  1. chmod 600 ~/.msmtprc
  2. chown $(whoami):$(whoami) ~/.msmtprc
  3. vi ~/.mailrc
set sendmail="/usr/bin/msmtp"
set use_from=yes
set from="ifor@designedbywaldo.com"
  1. Test mail: echo "This is a test" | mail -s "Testing Zoho" ifor@cors.tech

Install lightweight file watcher

  1. sudo apt install -y aide sudo aideinit
  2. sudo mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db
  3. sudo crontab -e
MAILTO="your_email@yourdomain.com"
0 3 * * * /usr/bin/aide --check | mail -s "AIDE Alert on $(hostname)" your_email@yourdomain.com
0 3 * * * /usr/bin/aide --check | gzip | mail -s "AIDE Alert on $(hostname)" -a "Content-Type: application/gzip" your_email@yourdomain.com # Optionally compress alerts
  1. Test Mail: sudo aide --check | mail -s "AIDE Test on $(hostname)" your_email@yourdomain.com

Add audit Log

sudo apt install -y auditd audispd-plugins sudo systemctl enable auditd

Projects

Laravel

  1. sudo mysql -u root -p
CREATE USER 'NEW_USERNAME'@'localhost' IDENTIFIED WITH caching_sha2_password BY 'NEW_STRONG_PASSWORD';
FLUSH PRIVILEGES;
# For each database required: 
CREATE DATABASE NEW_DATABASE_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
GRANT CREATE TEMPORARY TABLES, LOCK TABLES ON *.* TO 'NEW_USERNAME'@'localhost';
FLUSH PRIVILEGES;
GRANT REFERENCES, SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER ON NEW_DATABASE_NAME.* TO 'NEW_USERNAME'@'localhost';
FLUSH PRIVILEGES;
EXIT;
  1. git clone git@github.com:iforwms/karst-climber.git /var/www/karst-climber-laravel
  2. deploy-file .env <server> /var/www/<project-name>
  3. ./deploy-local
  4. sudo -u www-data crontab -e * * * * * cd /var/www/karst-climber-laravel && php artisan schedule:run >> /dev/null 2>&1
  5. fix-laravel-permissions
  6. php artisan storage:link
  7. php artisan config:clear
  8. php artisan route:clear
  9. php artisan view:clear
  10. sudo vi /etc/logrotate.d/laravel-worker and add the following:
/var/www/cindra-laravel/worker.log {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    copytruncate
    su www-data www-data
}
  1. Test logrotate:
    1. sudo logrotate -d /etc/logrotate.d/laravel-worker
    2. sudo logrotate -f /etc/logrotate.d/laravel-worker

/etc/supervisor/conf.d/laravel-worker.conf

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/karst-climber-laravel/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/karst-climber-laravel/worker.log
stopwaitsecs=3600
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=10
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"
sudo supervisorctl restart all

Wordpress

wp-config.php

  1. Set correct DB credentials
  2. Disable WP cron: define('DISABLE_WP_CRON', true);
  3. Disable file updates (web shells) : define('DISALLOW_FILE_EDIT', true);
  4. Allow auto-update core: define('WP_AUTO_UPDATE_CORE', true);
  5. Allow direct file access: define('FS_METHOD', 'direct');
  6. Increase memory limit define('WP_MEMORY_LIMIT', '256M');
  7. Set up cronjob: sudo crontab -e 5 * * * * /usr/bin/php /var/www/karst-climber-wp/wp-cron.php

Disable Google Fonts

  1. Elementor: Settings > Advanced

wp-cli (optional)

  1. curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
  2. php wp-cli.phar --info
  3. chmod +x wp-cli.phar
  4. sudo mv wp-cli.phar /usr/local/bin/wpsudo mv wp-cli.phar /usr/local/bin/wp
  5. wp --info
  6. Fix directory permissions
# Navigate to your WordPress root
cd /srv/www/wordpress

# Ensure the key writable directories exist
mkdir -p wp-content/upgrade-temp-backup/plugins
mkdir -p wp-content/wflogs
mkdir -p wp-content/uploads

# Set ownership to the web server user
sudo chown -R www-data:www-data wp-content/upgrade-temp-backup wp-content/wflogs wp-content/uploads

# Set directories to 755
sudo find wp-content/upgrade-temp-backup wp-content/wflogs wp-content/uploads -type d -exec chmod 755 {} \;

# Set files to 644
sudo find wp-content/upgrade-temp-backup wp-content/wflogs wp-content/uploads -type f -exec chmod 644 {} \;

Nginx Config - /etc/nginx/conf.d/SITE.conf

server {
#  listen 443 ssl;
  listen 80;

  server_name SITE.com;
  root /var/www/SITE/public;
  
#  # Lovable Only - not quite working
#  add_header Content-Security-Policy "
#  default-src 'self' https:;
#  script-src 'self' https: 'unsafe-inline' 'unsafe-eval';
#  style-src 'self' https: 'unsafe-inline';
#  img-src 'self' data: https:;
#  font-src 'self' https: data:;
#  connect-src 'self' https: wss://SUPABASE_ENDPOINT.supabase.co;
#  frame-src 'self' https:;
#  object-src 'none';
#  base-uri 'self';
#  form-action 'self';
#  " always;
  
  # Laravel Only
  add_header Content-Security-Policy "
    default-src 'self' https:;
    script-src 'self' https: 'unsafe-inline' 'unsafe-eval';
    style-src 'self' https: 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self' https: data:;
    connect-src 'self' https:;
    frame-src 'self' https:;
    object-src 'none';
    base-uri 'self';
    form-action 'self';
  " always;
    
  # Wordpress Only
  add_header Content-Security-Policy "
    default-src 'self' https:;
    script-src 'self' https: 'unsafe-inline' 'unsafe-eval';
    style-src 'self' https: 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self' https: data:;
    connect-src 'self' https:;
    frame-src 'self' https:;
    media-src 'self' https:;
    object-src 'none';
    base-uri 'self';
    form-action 'self' https:;
  " always;

  index index.php;

  charset utf-8;

  # -----------------------------
  # Allow Let's Encrypt ACME challenge
  # -----------------------------
  location /.well-known/acme-challenge/ {
    root /var/www/karst-climber-laravel/public;
    allow all;
  }

  location / {
#   try_files $uri $uri/ /index.html; # ReactJS
#   try_files $uri $uri/ /index.php?$query_string; # PHP
  }
  
  # -----------------------------
  # Deny PHP execution in storage, uploads, cache, tmp, files (Laravel & WordPress)
  # -----------------------------
  location ~* ^/(storage|uploads|wp-content/(uploads|cache|tmp|files|storage))/.*\.(php|phtml|phar)$ {
    deny all;
  }

  # -----------------------------
  # Deny access to sensitive files
  # -----------------------------
  location ~* ^/(?:\.env|composer\.(json|lock)|artisan|server\.php|phpunit\.xml|wp-config\.php|\.htaccess|readme\.html|license\.txt|xmlrpc\.php)$ {
      deny all;
  }

  # -----------------------------
  # Deny access to hidden files (dotfiles), except .well-known
  # -----------------------------
  location ~ /\.(?!well-known).* {
      deny all;
  }

  location = /favicon.ico { access_log off; log_not_found off; }
  location = /robots.txt  { access_log off; log_not_found off; }

  error_page 404 /index.php;

  error_log  /var/log/nginx/SITE-error.log error;

  sendfile off;

  location ~ \.php$ {
    try_files $uri =404;
    fastcgi_split_path_info ^(.+\.php)(/.+)$;
    fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_intercept_errors off;
    fastcgi_buffer_size 16k;
    fastcgi_buffers 4 16k;
    fastcgi_connect_timeout 300;
    fastcgi_send_timeout 300;
    fastcgi_read_timeout 300;
    # fastcgi_split_path_info ^(.+?\.php)(/.+)?$;
    # fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_hide_header X-Powered-By;
  }

# # Path to the SSL certificate and key files generated by acme.sh
#  ssl_certificate      /var/www/acme/SITE.com_ecc/fullchain.cer;
#  ssl_certificate_key  /var/www/acme/SITE.com_ecc/SITE.com.key;
#  Enable SSL protocols and specify the allowed ciphers
#  # OLD - ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
#  ssl_protocols TLSv1.2 TLSv1.3;
#  ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-HA256:ECDHE-RSA-AES128-GCM-SHA256';
#  ssl_prefer_server_ciphers on;
#  ssl_session_cache shared:SSL:10m;
#  ssl_session_timeout 10m;
}

#server {
#  listen 443 ssl;
#  server_name www.SITE.com;
#  ssl_certificate      /var/www/acme/SITE.com_ecc/fullchain.cer;
#  ssl_certificate_key  /var/www/acme/SITE.com_ecc/SITE.com.key;

# # Enable SSL protocols and specify the allowed ciphers
# # OLD - ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
#  ssl_protocols TLSv1.2 TLSv1.3;
#  ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SA256:ECDHE-RSA-AES128-GCM-SHA256';
#  ssl_prefer_server_ciphers on;
#  ssl_session_cache shared:SSL:10m;
#  ssl_session_timeout 10m;
#  return 301 https://SITE.com$request_uri;
#}

#server {
#  listen 80;
#  server_name SITE.com www.SITE.com;
#  return 301 https://SITE.com$request_uri;
#}

SSL Certificates

acme.sh

  1. curl https://get.acme.sh | sh -s email=ifor@cors.tech
  2. sudo mkdir -p /var/www/acme
  3. sudo cp -r /home/ifor/.acme.sh /var/www/acme
  4. sudo chown -R www-data:www-data /var/www/acme
  5. sudo chmod +x /var/www/acme/.acme.sh/acme.sh
  6. sudo crontab -u www-data -e:0 3 * * * /var/www/acme/.acme.sh/acme.sh --cron --home /var/www/acme --quiet
  7. For each site, install the certificate with --reloadcmd "systemctl reload nginx" to allow auto-renewing
   /var/www/acme/.acme.sh/acme.sh --home /home/ifor/.acme.sh --install-cert -d bikeasia.com -d www.bikeasia.com \
  --key-file /home/ifor/.acme.sh/bikeasia.com_ecc/bikeasia.com.key \
  --fullchain-file /home/ifor/.acme.sh/bikeasia.com_ecc/fullchain.cer \
  --reloadcmd "systemctl reload nginx"

SSL Certificates

sudo mkdir -p /var/www/SITE_NAME/public/.well-known/acme-challenge
sudo chown -R www-data:www-data /var/www/SITE_NAME/public/.well-known
sudo chmod -R 755 /var/www/SITE_NAME/public/.well-known
Wordpress/Standard:
sudo -u www-data -H /var/www/acme/.acme.sh/acme.sh \
  --issue \
  -d SITE_NAME.com -d www.SITE_NAME.com \
  -w /var/www/SITE_NAME \
  --server letsencrypt \
  --home /var/www/acme \
  --force

Laravel:
sudo -u www-data -H /var/www/acme/.acme.sh/acme.sh \
  --issue \
  -d SITE_NAME.com -d www.SITE_NAME.com \
  -w /var/www/SITE_NAME/public \
  --server letsencrypt \
  --home /var/www/acme \
  --force

Chinese Fonts

sudo apt-get update     
sudo apt-get install -y fonts-wqy-microhei fonts-wqy-microhei fonts-wqy-zenhei fonts-wqy-zenhei fonts-noto-cjk

Security Hardening

Wordpress

  1. sudo chown -R www-data:www-data /var/www/karst-climber-wp
  2. sudo find /var/www/karst-climber-wp -type d -exec chmod 755 {} \;
  3. sudo find /var/www/karst-climber-wp -type f -exec chmod 644 {} \;
  4. sudo chmod 600 /var/www/karst-climber-wp/wp-config.php
  5. echo "<?php // silence ?>" | sudo tee /var/www/karst-climber-wp/wp-content/uploads/index.php
  6. sudo find /var/www/karst-climber-wp/wp-content/uploads -type f -name "*.php" -delete
  7. sudo vi /wp-content/themes/<active-theme>/functions.php - remove_action('wp_head', 'wp_generator');

Email Setup

DKIM

SPF


Emergency Lockdown

  1. sudo -u www-data crontab -e and remove any malicious code
  2. sudo ufw default deny outgoing
  3. sudo ufw allow out to any port 53
  4. sudo ufw allow out to any port 443
  5. sudo ufw allow out to 127.0.0.1 port 3306
  6. cd /var/www/PROJECT-DIR
  7. sudo chown -R root:root .
  8. sudo chown -R www-data:www-data storage bootstrap/cache
  9. sudo find . -type d -exec chmod 755 {} \;
  10. sudo find . -type f -exec chmod 644 {} \;
  11. nginx site config:
location ~* /(storage|uploads|files|tmp|cache)/.*\.php$ {
    deny all;
}

location ~ /\.(?!well-known).* {
    deny all;
}

location ~* (base64_encode|eval\(|assert\(|shell_exec|passthru|system\() {
    deny all;
}
  1. php.ini:
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source

Migration from Hacked Server

  1. Backup storage on hacked server
sudo tar \
  --no-same-owner \
  --no-same-permissions \
  --exclude='storage/app/backup-temp' \
  --exclude='storage/framework' \
  --exclude='storage/logs' \
  --exclude='storage/debugbar' \
  --exclude='storage/app/temp' \
  --exclude='storage/media-library/temp' \
  -czf /tmp/laravel_storage_clean.tgz \
  storage/app \
  storage/media-library
  1. Check for malicious files (hacked server):
    1. ``tar -tzf /tmp/husfokus_laravel_storage_clean.tgz | egrep '.(php|phtml|phar|cgi|pl|py|sh|exe|js|html)$'`
    2. clamscan -r /tmp/husfokus_laravel_storage_clean.tgz *If low on memory, clamav will fail. To temporarily increase swap file size:
    3. sudo fallocate -l 2G /swapfile
    4. sudo chmod 600 /swapfile
    5. sudo mkswap /swapfile
    6. sudo swapon /swapfile
    7. swapon --show
    8. free -h
    9. sudo freshclam
    10. sudo swapoff /swapfile
    11. sudo rm -f /swapfile
sudo clamscan -r -i --max-filesize=25M --log=/tmp/clamav_scan.log \
/var/www/karst-climber/storage \
/var/www/karst-climber-wp/wp-content/uploads
  1. Backup mysql table:
mysqldump \
  --single-transaction \
  --routines \
  --triggers \
  --events \
  --hex-blob \
  --default-character-set=utf8mb4 \
  -u DB_USERNAME -p DATABASE_NAME > /tmp/SQL_FILENAME.sql
  
  gzip /tmp/SQL_FILENAME.sql
  
  ALTER USER 'DB_USER'@'localhost' IDENTIFIED BY 'NEW_SECURE_PASSWORD';
  1. Check for malware: zcat /tmp/SQL_FILENAME.sql.gz | egrep -i 'base64|eval\(|gzinflate|shell_exec|system\(|passthru|exec\('
  2. rsync -avz --progress karst:/tmp/laravel_storage_clean.tgz .
  3. rsync -avz --progress laravel_storage_clean.tgz karst:/tmp/
  4. rsync -avz --progress karst:/tmp/SQL_FILENAME.sql.gz .
  5. rsync -avz --progress SQL_FILENAME.sql.gz karst:/tmp/
  6. On the new server:
    1. cd /var/www/karst-climber-laravel
    2. sudo mkdir -p /var/www/karst-climber-laravel/storage
    3. tar -tzf /tmp/laravel_storage_clean.tgz | head -n 20
    4. sudo tar --no-same-owner --no-same-permissions -xzf /tmp/laravel_storage_clean.tgz -C /var/www/karst-climber-laravel/
    5. sudo chown -R www-data:www-data storage
    6. sudo find storage -type d -exec chmod 750 {} \;
    7. sudo find storage -type f -exec chmod 640 {} \;
    8. sudo chmod -R ug+rw storage bootstrap/cache
    9. find storage -type f -perm /111
    10. find /var/www/karst-climber-laravel/storage -type f \ \( -iname "*.php" -o -iname "*.phtml" -o -iname "*.phar" -o -iname "*.sh" \)
    11. Import database:
      1. gunzip /tmp/cindra_laravel.sql
      2. mysql -u cindra_laravel_user -p cindra_laravel < /tmp/cindra_laravel.sql

Server Migration

1. Backup Databases

sudo mysqldump --all-databases > ~/downloads/all-databases.sql

2. Transfer Files: SSL certs, nginx config, ssh config, .htpasswd, code

Old server to new server: sudo rsync -avz --progress /path/to/dir/ root@NEW_IP:/path/to/dir/ New server from old server: rsync -avz --progress ifor@OLD_IP:/path/to/dir/ /path/to/dir/

for f in /var/www/ /etc/letsencrypt/ /var/lib/letsencrypt/ /etc/nginx/h5bp/ /etc/apache2/.htpasswd /home/ifor/.acme.sh/ /etc/nginx/conf.d/ /home/ifor/downloads/ /home/ifor/backups/; do sudo rsync -avz --progress "$f" root@NEW_IP:"$f"; done

Restore Databases

  1. sudo mysql < ~/downloads/all-databases.sql
  2. Create user for each Laravel project (see earlier)
  3. Add privileges (GRANT ALL PRIVILEGES ON *.* TO 'server'@'localhost' WITH GRANT OPTION;)

Small Server Optimisations

Increase swapfile

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
swapon --show
free -h

svi /etc/fstab:

/swapfile none swap sw 0 0

svi /etc/sysctl.conf:

vm.swappiness=10

PHP FPM

svi /etc/php/8.3/fpm/pool.d/www.conf

pm = ondemand
pm.max_children = 5
pm.process_idle_timeout = 10s
pm.max_requests = 200

svi /etc/php/8.3/fpm/php.ini

opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.revalidate_freq=60

MySQL

svi /etc/mysql/mysql.conf.d/mysqld.cnf

innodb_buffer_pool_size = 128M
innodb_log_buffer_size = 16M
max_connections = 20
key_buffer_size = 32M
tmp_table_size = 32M
max_heap_table_size = 32M
table_open_cache = 400

Services

Disable unused services: systemctl list-unit-files --type=service --state=enabled