Security & Operations
SSL/TLS, DNS, Cloudflare, UFW, Fail2ban, SSH hardening, complete troubleshooting guide, and production deployment workflows.
Section 9 — SSL, Domains & DNS
9.1 DNS Record Types
| Record | Purpose | Example |
|---|---|---|
| A | Domain → IPv4 address | example.com → 203.0.113.42 |
| AAAA | Domain → IPv6 address | example.com → 2001:db8::1 |
| CNAME | Alias to another domain | www → example.com |
| MX | Email server routing | @ → mail.example.com (priority 10) |
| TXT | SPF, DKIM, verification | v=spf1 ip4:203.0.113.42 ~all |
| NS | Authoritative nameservers | ns1.cloudflare.com |
| PTR | Reverse DNS (IP → domain) | Used for email reputation |
| CAA | Which CAs can issue SSL | 0 issue "letsencrypt.org" |
Setting Up DNS for a New VPS (Example)
Server IP: 203.0.113.42 Domain: mysite.com
DNS Records to create:
Type Name Value TTL
──────────────────────────────────────────
A @ 203.0.113.42 Auto
A www 203.0.113.42 Auto
A api 203.0.113.42 Auto
A mail 203.0.113.42 Auto
CNAME blog mysite.com Auto
MX @ mail.mysite.com 10
TXT @ v=spf1 ip4:203.0.113.42 ~all
# Check DNS propagation
dig mysite.com A
nslookup mysite.com 8.8.8.8 # Ask Google DNS
nslookup mysite.com 1.1.1.1 # Ask Cloudflare DNS
# Online: https://dnschecker.org
After changing DNS records, wait 15 minutes to 48 hours. With Cloudflare as your nameserver, it's usually under 5 minutes.
9.2 Cloudflare — Setup & Benefits
Cloudflare is a CDN + DNS provider that sits in front of your server, providing DDoS protection, free SSL, caching, and a Web Application Firewall.
Without Cloudflare: User → your-server-ip
With Cloudflare: User → Cloudflare global network → your-server-ip
| Benefit | What It Does |
|---|---|
| DDoS Protection | Absorbs attack traffic before it hits your server |
| CDN | Caches static files at edge locations worldwide |
| Free SSL | Automatic HTTPS without server-side Certbot |
| WAF | Blocks SQL injection, XSS, and common attacks |
| Analytics | Traffic statistics and bot detection |
Never use Flexible SSL with Cloudflare if your server has HTTPS redirects. It creates redirect loops. Use Full (Strict) with a Let's Encrypt cert on your server.
9.3 Let's Encrypt & Certbot
Let's Encrypt is a free Certificate Authority. Certbot automatically requests and renews certificates.
# Install Certbot
sudo apt install certbot
# For Nginx
sudo apt install python3-certbot-nginx
sudo certbot --nginx -d mysite.com -d www.mysite.com
# For Apache
sudo apt install python3-certbot-apache
sudo certbot --apache -d mysite.com -d www.mysite.com
# Standalone (no web server running)
sudo certbot certonly --standalone -d mysite.com
# Webroot method (web server keeps running)
sudo certbot certonly --webroot -w /var/www/mysite -d mysite.com
# Test auto-renewal (certificates renew every 90 days)
sudo certbot renew --dry-run
# Force renew
sudo certbot renew --force-renewal --cert-name mysite.com
# View all certificates + expiry dates
sudo certbot certificates
/etc/letsencrypt/live/mysite.com/
├── cert.pem ← Your certificate
├── chain.pem ← Intermediate certificates
├── fullchain.pem ← cert.pem + chain.pem (use this in Nginx/Apache)
└── privkey.pem ← Private key — PROTECT THIS!
Auto-Reload Web Server After Renewal
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-webserver.sh
#!/bin/bash
systemctl reload nginx
# or: systemctl reload apache2
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-webserver.sh
HTTP → HTTPS Redirect
# Nginx
server {
listen 80;
server_name mysite.com www.mysite.com;
return 301 https://$server_name$request_uri;
}
# Apache .htaccess
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
9.4 SSL Troubleshooting
| Issue | Diagnosis | Fix |
|---|---|---|
| Certificate expired | sudo certbot certificates | sudo certbot renew --force-renewal |
| Mixed content warning | HTTP resources on HTTPS page | Use wp search-replace http:// https:// or update app config |
| Cloudflare redirect loop | SSL mode is "Flexible" | Change Cloudflare SSL mode to "Full (Strict)" |
| "Not Secure" badge | HTTP loaded resources | Check browser DevTools Network tab for HTTP requests |
| Wrong domain on cert | openssl s_client -connect mysite.com:443 | Re-run Certbot for the correct domain |
Section 10 — Security Hardening
10.1 UFW Firewall
UFW (Uncomplicated Firewall) is the standard firewall tool on Ubuntu. Block everything you don't need.
If you enable UFW without allowing SSH first, you will lock yourself out of the server. This requires a console login to fix.
# Default policies
sudo ufw default deny incoming
sudo ufw default allow outgoing
# ALWAYS allow SSH first
sudo ufw allow ssh # Port 22
sudo ufw allow 22/tcp # Same — explicit
# Allow web traffic
sudo ufw allow http # Port 80
sudo ufw allow https # Port 443
sudo ufw allow 'Nginx Full' # Ports 80 + 443 (Nginx profile)
# Allow from specific IP only
sudo ufw allow from 203.0.113.10 to any port 3306 # MySQL from one IP
sudo ufw allow from 203.0.113.10 to any port 22 # SSH from office IP
# Enable UFW
sudo ufw enable
# Check status
sudo ufw status verbose
# Delete a rule
sudo ufw delete allow 3306
# Standard production setup
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https
sudo ufw enable
10.2 Fail2ban — Brute Force Protection
Fail2ban monitors log files and automatically bans IPs after too many failed attempts.
sudo apt install fail2ban
# Create local config (never edit jail.conf directly)
sudo nano /etc/fail2ban/jail.local
[DEFAULT]
bantime = 3600 ; Ban for 1 hour
findtime = 600 ; 10-minute window
maxretry = 5 ; Ban after 5 failures
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3 ; Stricter for SSH
[nginx-http-auth]
enabled = true
logpath = %(nginx_error_log)s
maxretry = 6
[apache-auth]
enabled = true
logpath = %(apache_error_log)s
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# Check what jails are active
sudo fail2ban-client status
# Check who's banned for SSH
sudo fail2ban-client status sshd
# Unban an IP
sudo fail2ban-client set sshd unbanip 203.0.113.42
10.3 SSH Hardening
sudo nano /etc/ssh/sshd_config
# Recommended settings
Port 2222 # Change default port (optional but helpful)
PermitRootLogin no # Never login as root via SSH
PasswordAuthentication no # Use keys only (set AFTER setting up keys!)
PubkeyAuthentication yes
PermitEmptyPasswords no
MaxAuthTries 3
ClientAliveInterval 300 # Disconnect idle sessions after 5 min
ClientAliveCountMax 2
AllowUsers ubuntu developer # Only allow specific users
Protocol 2
# Test config before restarting
sudo sshd -t # No output = OK
sudo systemctl restart sshd
Setting Up SSH Key Authentication
# On your LOCAL machine — generate key pair
ssh-keygen -t ed25519 -C "your-email@example.com"
# Creates: ~/.ssh/id_ed25519 (private) and ~/.ssh/id_ed25519.pub (public)
# Copy public key to server
ssh-copy-id ubuntu@server-ip
# Or manually:
cat ~/.ssh/id_ed25519.pub | ssh ubuntu@server-ip \
"mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
# Now test key-based login
ssh ubuntu@server-ip # Should NOT ask for password
# Only THEN disable password auth in sshd_config
PasswordAuthentication no
10.4 Server Security Checklist
- Change default SSH port
- Disable root SSH login (
PermitRootLogin no) - Enable SSH key-only authentication (
PasswordAuthentication no) - Install and configure UFW firewall
- Install Fail2ban for brute force protection
- Keep packages updated:
sudo apt update && sudo apt upgrade - Remove unused packages and services
- Disable directory listing in web server
- Never use 777 permissions in production
- Secure .env files (600 permissions, never commit to Git)
- Set
APP_DEBUG=falsein Laravel production - Disable PHP
display_errorsin production - Use strong, unique MySQL passwords
- Restrict MySQL to localhost (no remote root access)
- Keep SSL certificates up to date (auto-renew with Certbot)
- Enable security headers (
X-Frame-Options,X-Content-Type-Options) - Regular database and file backups to off-server location
- Monitor logs for suspicious activity
- Use HTTPS everywhere — redirect all HTTP to HTTPS
Section 11 — Troubleshooting Master Guide
11.1 The Debugging Mindset
Before panicking, follow this structured debugging approach:
Problem appears
↓
1. WHAT exactly is the symptom? (404? 502? Server down? Slow?)
↓
2. WHEN did it start? (After deploy? After reboot? Randomly?)
↓
3. WHAT changed? (Code? Config? Server update? DNS?)
↓
4. CHECK THE LOGS first — they almost always tell you the cause
↓
5. ISOLATE the problem (web server? PHP? DB? DNS? App code?)
↓
6. FIX and TEST
↓
7. DOCUMENT what caused it and how you fixed it
11.2 Log File Locations — Where to Look First
# Priority order when debugging:
# 1. Web server error log (MOST important — start here)
sudo tail -n 50 /var/log/nginx/error.log
sudo tail -n 50 /var/log/apache2/error.log
# 2. Site-specific error log
sudo tail -n 50 /var/log/nginx/mysite-error.log
# 3. PHP-FPM log
sudo tail -n 50 /var/log/php8.2-fpm.log
# 4. Application logs
tail -n 50 /var/www/mylaravelapp/storage/logs/laravel.log
pm2 logs my-node-app
# 5. Service status
sudo systemctl status nginx
sudo systemctl status php8.2-fpm
sudo systemctl status mysql
# 6. System logs
sudo journalctl -u nginx -n 50
sudo tail -n 50 /var/log/syslog
# 7. Disk space (often forgotten!)
df -h
# 8. Memory
free -h
11.3 HTTP Status Code Reference
| Code | Name | Common Cause | Fix |
|---|---|---|---|
| 200 | OK | ✅ Success | — |
| 201 | Created | Resource created (API POST) | — |
| 301 | Moved Permanently | Domain migration, HTTP→HTTPS | Ensure redirect is correct |
| 302 | Found (Temp) | Temporary redirect | — |
| 304 | Not Modified | Cached content still valid | — |
| 400 | Bad Request | Malformed request, invalid JSON | Check request format/headers |
| 401 | Unauthorized | Not logged in, invalid token | Check auth headers/session |
| 403 | Forbidden | Wrong permissions, .htaccess deny | chown, chmod, check VHost config |
| 404 | Not Found | File missing, routing not set up | Check URL, file, try_files |
| 405 | Method Not Allowed | GET on a POST-only route | Check API route method |
| 413 | Payload Too Large | File too big | Increase PHP/Nginx upload limits |
| 422 | Unprocessable Entity | Validation failed | Check input data |
| 429 | Too Many Requests | Rate limit hit | Wait, or increase limit |
| 500 | Internal Server Error | PHP error, .htaccess error | Check error.log |
| 502 | Bad Gateway | PHP-FPM/Node.js not running | Restart upstream service |
| 503 | Service Unavailable | Overloaded or maintenance mode | Check resources, php artisan up |
| 504 | Gateway Timeout | Upstream too slow | Increase timeout, fix slow query |
11.4 Debugging 500 Internal Server Error
Don't panic. Open the error log. The log will almost always tell you exactly what went wrong and on which line.
| What Log Says | Cause | Fix |
|---|---|---|
PHP Fatal error: Uncaught Error... | PHP code error | Fix the code on the line mentioned |
PHP Parse error: syntax error... | Syntax error in PHP file | Run php -l problematic-file.php |
Invalid command 'RewriteEngine' | mod_rewrite not enabled | sudo a2enmod rewrite && reload apache2 |
Call to undefined function curl_init() | Missing PHP extension | sudo apt install php8.2-curl |
Unable to write to storage/ | Wrong permissions | chmod -R 775 storage |
No application encryption key | Laravel APP_KEY missing | php artisan key:generate |
SQLSTATE[HY000] Connection refused | MySQL not running or wrong creds | Check MySQL status, check .env |
# Debugging commands
sudo tail -n 100 /var/log/nginx/error.log
sudo tail -n 100 /var/log/apache2/error.log
tail -n 100 /var/www/myapp/storage/logs/laravel.log
# Test PHP file syntax
php -l /var/www/myapp/routes/web.php
11.5 Debugging 502 Bad Gateway
502 means Nginx received no response from the upstream application (PHP-FPM or Node.js).
# Check Nginx error log first
sudo tail -n 50 /var/log/nginx/error.log
# Look for: "connect() failed (111: Connection refused) while connecting to upstream"
# For PHP-FPM:
sudo systemctl status php8.2-fpm
sudo systemctl restart php8.2-fpm
ls -la /run/php/php8.2-fpm.sock # Does the socket exist?
# For Node.js:
pm2 status # Is app running?
pm2 restart my-app
sudo ss -tlnp | grep :3000 # Is something on port 3000?
# Check Nginx config matches reality
# Config says: fastcgi_pass unix:/run/php/php8.2-fpm.sock
# Does the socket file actually exist?
11.6 Debugging 403 Forbidden
| Cause | Check | Fix |
|---|---|---|
| Wrong ownership | ls -la /var/www/mysite/ | sudo chown -R www-data:www-data /var/www/mysite |
| Permissions too strict | ls -la /var/www/mysite/ | sudo chmod -R 755 /var/www/mysite |
| Apache AllowOverride None | Check VirtualHost config | Change to AllowOverride All |
| Directory listing disabled + no index | ls /var/www/mysite/ | Create an index.php file |
| .htaccess Deny rule | cat /var/www/mysite/.htaccess | Remove or fix the Deny rule |
11.7 Debugging 404 Not Found
| Cause | Fix |
|---|---|
| File genuinely missing | Upload the file to the correct path |
Laravel: DocumentRoot not pointing to public/ | Fix VirtualHost/server block DocumentRoot |
| WordPress: mod_rewrite not enabled | sudo a2enmod rewrite + AllowOverride All |
Nginx: try_files missing | Add try_files $uri $uri/ /index.php?$query_string |
| React Router: refresh returns 404 | Add try_files $uri $uri/ /index.html to Nginx |
| Domain pointing to wrong server | Check dig mysite.com A |
11.8 Troubleshooting Checklist Template
When anything breaks on a server, work through this list systematically:
- What is the exact error message? (Read it carefully word by word)
- When did it start? (after deploy? after reboot? randomly?)
- What was the last change made?
- Check web server error log (
tail -f /var/log/nginx/error.log) - Check application log (
laravel.log,pm2 logs) - Check PHP-FPM log
- Is the web server running? (
systemctl status nginx) - Is PHP-FPM running? (
systemctl status php8.2-fpm) - Is MySQL running? (
systemctl status mysql) - Is the Node app running? (
pm2 status) - Is disk space OK? (
df -h) — disk full causes strange errors - Are file permissions correct? (
ls -la /var/www/mysite) - Is
.envcorrectly configured? - Is the domain pointing to this server? (
dig domain.com A) - Is SSL valid? (
sudo certbot certificates)
Section 12 — Deployment Workflows
12.1 Git for Deployments
sudo apt install git
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
# Clone a repository
git clone https://github.com/yourorg/project.git
git clone git@github.com:yourorg/project.git # SSH (no password prompts)
# Pull latest changes
git pull origin main
git status # Check what changed
git log --oneline -20 # Recent commits
git branch # Current branch
Set Up SSH Key for GitHub/GitLab
# Generate key on server
ssh-keygen -t ed25519 -C "server@mysite.com"
# Press Enter for all prompts
# View public key
cat ~/.ssh/id_ed25519.pub
# Add this key to GitHub: Settings → SSH Keys → New SSH Key
# Test
ssh -T git@github.com
# "Hi username! You've successfully authenticated"
12.2 Laravel Deployment Workflow
#!/bin/bash
# /usr/local/bin/deploy-laravel.sh
# Run: sudo /usr/local/bin/deploy-laravel.sh
set -e # Exit on any error
APP_DIR="/var/www/mylaravelapp"
echo "Starting Laravel deployment..."
cd $APP_DIR
# Enable maintenance mode
php artisan down --message="Deploying update. Back in a minute." --retry=60
# Pull latest code
git fetch origin
git reset --hard origin/main
# Install dependencies (no dev, optimize autoloader)
composer install --no-dev --optimize-autoloader
# Run migrations
php artisan migrate --force
# Clear and rebuild all caches
php artisan config:clear
php artisan config:cache
php artisan route:clear
php artisan route:cache
php artisan view:clear
php artisan view:cache
php artisan cache:clear
# Restart queue workers (picks up new code)
php artisan queue:restart
# Fix permissions
chown -R www-data:www-data $APP_DIR/storage $APP_DIR/bootstrap/cache
# Disable maintenance mode
php artisan up
echo "Deployment complete! ✓"
sudo chmod +x /usr/local/bin/deploy-laravel.sh
sudo /usr/local/bin/deploy-laravel.sh
12.3 WordPress Deployment Workflow
# ALWAYS BACKUP BEFORE UPDATING
# Backup database
mysqldump -u root -p wordpress_db > /backup/wp_$(date +%Y%m%d).sql
# Backup files
tar -czvf /backup/wp_files_$(date +%Y%m%d).tar.gz /var/www/myclientsite
# Update via WP-CLI
cd /var/www/myclientsite
sudo -u www-data wp core update
sudo -u www-data wp plugin update --all
sudo -u www-data wp theme update --all
# Clear cache
sudo -u www-data wp cache flush
sudo -u www-data wp rewrite flush
# Verify site works
curl -I https://myclientsite.com
12.4 React App Deployment
#!/bin/bash
# /usr/local/bin/deploy-react.sh
APP_DIR="/var/www/react-app"
WEB_DIR="/var/www/myreactapp"
echo "Deploying React app..."
cd $APP_DIR
git fetch origin
git reset --hard origin/main
npm install --production=false
npm run build
cp -r build/* $WEB_DIR/
chown -R www-data:www-data $WEB_DIR
echo "React deployment complete! ✓"
# Or: build locally, sync to server
npm run build
rsync -avz --delete ./build/ ubuntu@server-ip:/var/www/myreactapp/
12.5 Node.js App Deployment
#!/bin/bash
# /usr/local/bin/deploy-node.sh
APP_DIR="/var/www/nodeapp"
APP_NAME="my-api"
echo "Deploying Node.js app..."
cd $APP_DIR
# Pull latest code
git fetch origin
git reset --hard origin/main
# Install dependencies
npm install --production
# Zero-downtime reload with PM2
pm2 reload $APP_NAME
echo "Node.js deployment complete! ✓"
sudo chmod +x /usr/local/bin/deploy-node.sh
sudo /usr/local/bin/deploy-node.sh
Rollback Strategy
# Git rollback to previous commit
cd /var/www/mylaravelapp
# Find the last good commit
git log --oneline -10
# Roll back to a specific commit
git reset --hard <commit-hash>
# Restore caches
php artisan config:cache
php artisan route:cache
php artisan up # Remove maintenance mode