A structured guide for deploying and securing web applications
Build a structured, technically correct guide that teaches:
Tone: Clear, practical, no fluff, no marketing language. Focus on real-world habits.
Hosting provides the infrastructure (servers, storage, network) to make websites accessible on the internet. A server is a computer that runs continuously, serving content to visitors.
| Type | Description | Use Case | Control Level |
|---|---|---|---|
| Shared Hosting | Multiple websites on one server | Static sites, blogs | Minimal |
| VPS (Virtual Private Server) | Virtualized server with dedicated resources | Web apps, APIs | Full root access |
| Dedicated Server | Physical server exclusively yours | High-traffic applications | Complete |
| Serverless | Cloud-managed functions, no server management | Event-driven apps | None (managed) |
A domain is a human-readable address (example.com). DNS (Domain Name System) translates domains to IP addresses.
IPv4: 32-bit address (192.168.1.1). IPv6: 128-bit address (2001:0db8::1). Public IPs are routable on the internet; private IPs are for local networks.
A server that sits between clients and backend servers. Handles SSL termination, load balancing, and caching. Common implementations: Nginx, Caddy, Traefik.
Transport Layer Security encrypts data between client and server. TLS 1.3 is current standard. Certificates are issued by Certificate Authorities (Let's Encrypt, free).
Free hosting for static websites directly from GitHub repositories.
username.github.io/
├── index.html
├── css/
│ └── style.css
├── js/
│ └── app.js
└── assets/
└── images/
Platform-as-a-Service for frontend frameworks and static sites.
# Install CLI
npm i -g vercel
# Login
vercel login
# Deploy
vercel --prod
# Install CLI
npm install -g netlify-cli
# Login
netlify login
# Deploy
netlify deploy --prod --dir=build
Deploying to a virtual private server provides full control.
# Default connection (password auth - temporary)
ssh root@your_server_ip
# Verify host key fingerprint matches provider dashboard
# Connect as root (only for initial setup)
ssh root@server_ip
# Update system packages
apt update && apt upgrade -y
# Install essential tools
apt install -y curl wget vim ufw fail2ban
# Create user
adduser deploy
# Add to sudo group
usermod -aG sudo deploy
# Switch to new user
su - deploy
# Verify sudo access
sudo whoami
On local machine:
# Generate SSH key pair (if not exists)
ssh-keygen -t ed25519 -C "[email protected]"
# Copy public key to server
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@server_ip
# Edit SSH config
sudo nano /etc/ssh/sshd_config
# Modify these lines:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
MaxAuthTries 3
# Restart SSH
sudo systemctl restart sshd
# Default deny
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH (before enabling)
sudo ufw allow 22/tcp
# Allow HTTP/HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Enable firewall
sudo ufw enable
# Check status
sudo ufw status verbose
# Remove old versions
sudo apt remove docker docker-engine docker.io
# Install dependencies
sudo apt install -y apt-transport-https ca-certificates gnupg lsb-release
# Add Docker GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Add repository
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io
# Add user to docker group
sudo usermod -aG docker deploy
# Verify
docker --version
Create application structure:
mkdir -p ~/app
cd ~/app
# Create Dockerfile
cat > Dockerfile << 'EOF'
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]
EOF
# Install Caddy
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
# Configure Caddyfile
sudo nano /etc/caddy/Caddyfile
example.com {
reverse_proxy localhost:3000
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "1; mode=block"
}
}
# Reload Caddy
sudo systemctl reload caddy
# Enable auto-start
sudo systemctl enable caddy
Caddy automatically provisions and renews certificates. For manual certbot with Nginx:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
# Auto-renewal test
sudo certbot renew --dry-run
Create systemd service for application:
sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=My Application
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/app
ExecStart=/usr/bin/docker run --rm -p 3000:3000 myapp:latest
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
# Check logs
sudo journalctl -u myapp -f
# Install restic for backups
sudo apt install restic
# Initialize backup repository
restic init --repo /backup/repo
# Create backup script
cat > ~/backup.sh << 'EOF'
#!/bin/bash
export RESTIC_PASSWORD="your-secure-password"
restic -r /backup/repo backup /home/deploy/app/data
restic -r /backup/repo forget --keep-last 7 --keep-daily 30
EOF
chmod +x ~/backup.sh
# Add to crontab (daily at 2 AM)
0 2 * * * /home/deploy/backup.sh
sudo apt install fail2ban
sudo tee /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
backend = systemd
[sshd]
enabled = true
port = 22
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
EOF
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# Check status
sudo fail2ban-client status sshd
sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
What: Automated attempts to guess SSH credentials
How: Bots scan internet for port 22, try common passwords
Prevention: Disable password auth, use keys only, move SSH to non-standard port, deploy Fail2ban
What: Database accessible without authentication
How: Default configurations bind to 0.0.0.0 without password
Prevention: Bind to 127.0.0.1 only, enable auth, firewall block port 27017/6379
What: Malicious SQL commands through user input
How: Unsanitized input concatenated into queries
Prevention: Use parameterized queries/prepared statements, ORM frameworks, input validation
What: Injection of client-side scripts into web pages
How: User input displayed without encoding
Prevention: Output encoding, Content Security Policy, HttpOnly cookies
What: Unauthorized actions performed on authenticated session
How: Malicious site triggers requests to target site
Prevention: CSRF tokens, SameSite cookies, validate Origin header
What: Compromise through dependencies
How: Malicious npm packages, compromised build tools
Prevention: Lock dependencies (package-lock.json), audit with npm audit, private registry
What: Overly permissive cross-origin resource sharing
How: Access-Control-Allow-Origin: * on sensitive endpoints
Prevention: Whitelist specific origins, validate origin server-side
What: Container escape or privilege escalation
How: Running containers as root, mounting Docker socket
Prevention: Run as non-root user, read-only filesystems, drop capabilities
What: Exposure of environment variables
How: Committed to git, exposed in error messages
Prevention: .gitignore .env, validate no secrets in repo with git-secrets, use secret management
What: Publicly accessible cloud storage
How: Default public permissions, misconfigured bucket policies
Prevention: Block public access settings, IAM policies, regular audits
Create .github/workflows/deploy.yml:
name: Deploy to Production
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /home/deploy/app
git pull origin main
docker build -t myapp:latest .
docker stop myapp || true
docker rm myapp || true
docker run -d --name myapp -p 3000:3000 myapp:latest
# Production .env file (not committed)
NODE_ENV=production
DATABASE_URL=postgresql://user:pass@localhost/db
JWT_SECRET=use-random-256-bit-string-here
API_KEY=external-service-key
Implement at reverse proxy or application level:
# Nginx rate limiting
limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s;
server {
location /api/ {
limit_req zone=one burst=20 nodelay;
}
}
# Centralized logging with journald
sudo journalctl -u myapp -f -n 100
# Structured logging format (JSON)
{"timestamp":"2024-01-01T00:00:00Z","level":"error","message":"DB connection failed","request_id":"uuid"}
# DNS configuration
Type: A
Name: example.com
Content: your_server_ip
Proxy status: Proxied (orange cloud)
# Always Use HTTPS: On
# Security Level: Medium
# Browser Cache TTL: 4 hours
What: Attacker tricks the server into making HTTP requests to internal resources
How: URL parameters passed to server-side HTTP clients without validation (e.g. fetch(req.body.url))
Prevention: Whitelist allowed domains, block private IP ranges (10.x, 172.16.x, 192.168.x, 169.254.x), use a dedicated egress proxy
What: Access to files outside the intended directory by manipulating paths
How: Input like ../../etc/passwd passed to file read functions
Prevention: Resolve and validate paths against allowed base directory, use path.resolve() + startsWith check, never expose raw user input to filesystem calls
What: Attacker overlays invisible iframe to trick users into clicking hidden elements
How: Your page embedded in malicious site's iframe
Prevention: Set X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none'
What: Account takeover via credential stuffing or phishing even with strong passwords
How: Leaked credential databases reused across services (credential stuffing)
Prevention: Enforce TOTP/WebAuthn for all admin access; use speakeasy or otplib for TOTP; consider passkeys (FIDO2) for users
What: Secrets baked into Docker image layers remain accessible even after deletion
How: COPY .env or RUN commands with credentials in Dockerfile leave traces in image history
Prevention: Use --secret BuildKit mounts, inject secrets at runtime via environment variables or secrets managers (HashiCorp Vault, AWS Secrets Manager, Doppler)
Never store secrets in code or Docker images. Use a dedicated secrets manager:
# Docker: inject at runtime, never baked in
docker run -e DATABASE_URL="$DATABASE_URL" -e JWT_SECRET="$JWT_SECRET" myapp:latest
# Or use Docker secrets (Swarm/Compose)
echo "my_secret_value" | docker secret create db_password -
# .gitignore — always include:
.env
.env.local
.env.production
*.key
*.pem
# Install trufflehog (scans git history)
pip install trufflehog
trufflehog git file://. --only-verified
# Or gitleaks
gitleaks detect --source . -v
# Pre-commit hook with detect-secrets
pip install detect-secrets
detect-secrets scan > .secrets.baseline
# Add as pre-commit hook to prevent future commits
CSP is one of the most powerful defenses against XSS. Many guides skip it — don't.
# Nginx — add to server block
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# Report-only mode (test without breaking things)
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report;" always;
| Directive | Purpose |
|---|---|
default-src 'self' | Baseline: only load resources from same origin |
script-src | Where JavaScript can be loaded from |
frame-ancestors 'none' | Prevents your page being embedded (clickjacking) |
upgrade-insecure-requests | Forces HTTP resources to be requested as HTTPS |
report-uri /endpoint | Browser reports violations here — use in staging |
⚠ Warning: Never use unsafe-inline or unsafe-eval in script-src — this defeats the purpose of CSP entirely. Use nonces instead.