# API Tutorial & Security Guide
**Your Stack Covered:**
– Ubuntu Server with Apache2 as a reverse proxy
– SQL databases (MySQL / MariaDB / PostgreSQL) and database tools
– Cloudflare for DNS and domain management
– Cloudflare Pages for hosting HTML & JS webpages and dashboards
—
## Table of Contents
1. [Architecture Overview](#1-architecture-overview)
2. [Part 1 — API Tutorial](#2-part-1–api-tutorial)
   – [What is an API?](#what-is-an-api)
   – [REST API Fundamentals](#rest-api-fundamentals)
   – [HTTP Status Codes Cheat Sheet](#http-status-codes-cheat-sheet)
   – [Building Your First API (choose your language)](#building-your-first-api)
   – [Consuming the API from JavaScript](#consuming-the-api-from-javascript)
3. [Part 2 — Apache2 Reverse Proxy](#3-part-2–apache2-reverse-proxy)
   – [Install & Enable Modules](#install–enable-modules)
   – [Virtual Host Configuration](#virtual-host-configuration)
   – [SSL with Let’s Encrypt](#ssl-with-lets-encrypt)
   – [Security Headers](#security-headers)
4. [Part 3 — Database Connection & Security](#4-part-3–database-connection–security)
   – [Best Practices for SQL](#best-practices-for-sql)
   – [Secure Connection Examples (PHP PDO, Node.js, Python)](#secure-connection-examples)
   – [Database User Management & Least Privilege](#database-user-management–least-privilege)
   – [Backups](#backups)
5. [Part 4 — Cloudflare Setup](#5-part-4–cloudflare-setup)
   – [DNS Configuration](#dns-configuration)
   – [Cloudflare Pages for Your Frontend](#cloudflare-pages-for-your-frontend)
   – [Connecting the Frontend to the API (CORS)](#connecting-the-frontend-to-the-api-cors)
   – [Protecting Your Origin Server](#protecting-your-origin-server)
6. [Part 5 — API Security Checklist](#6-part-5–api-security-checklist)
7. [Troubleshooting Common Issues](#7-troubleshooting-common-issues)
8. [Quick Reference — Recommended Config Snippets](#8-quick-reference–recommended-config-snippets)
—
## 1. Architecture Overview
Here is how your pieces fit together:
“`
User’s Browser
      │
      ▼
Cloudflare (DNS, CDN, WAF, SSL, Rate Limiting)
      │
      ├────────────────────────────────┐
      ▼                                ▼
Cloudflare Pages                 Your Ubuntu Server
(HTML/JS dashboards                (Apache2 reverse proxy)
 served statically)                      │
                                         ▼
                                    API App (Node.js / Python / PHP)
                                         │
                                         ▼
                                    SQL Database
                                   (MySQL/PostgreSQL)
“`
– **Cloudflare DNS** routes traffic (proxied = orange cloud → gets CDN/WAF protection).
– **Cloudflare Pages** serves your static frontend (HTML/JS dashboards) on your domain.
– **Your Ubuntu server** runs the API behind Apache2, which reverse-proxies requests to your API app.
– **Apache2** can also terminate SSL, enforce security headers, and restrict who can reach your API.
– **The SQL database** is only ever reached by your API app — never directly by browsers.
—
## 2. Part 1 — API Tutorial
### What is an API?
An **API (Application Programming Interface)** is the contract between a client (e.g., your JS dashboard) and a server. Your dashboard calls the API to fetch, create, update, or delete data — the API talks to the database and returns the result, usually as **JSON**.
### REST API Fundamentals
REST (Representational State Transfer) is the most common API style. Key ideas:
| Concept | Meaning |
|—|—|
| **Resource** | A thing your API manages — e.g., `users`, `orders`, `devices` |
| **Endpoint** | A URL that points to a resource — e.g., `/api/v1/users` |
| **Method** | The action to perform on the resource |
| **Stateless** | Each request contains all the info needed (no server-side session) |
| **JSON** | The standard request/response format for web APIs |
### HTTP Methods
| Method | Purpose | Example |
|—|—|—|
| `GET` | Read data (never changes state) | `GET /api/v1/users` |
| `POST` | Create a new resource | `POST /api/v1/users` |
| `PUT` | Replace an entire resource | `PUT /api/v1/users/42` |
| `PATCH` | Partially update a resource | `PATCH /api/v1/users/42` |
| `DELETE` | Remove a resource | `DELETE /api/v1/users/42` |
### HTTP Status Codes Cheat Sheet
| Code | Meaning | When to use |
|—|—|—|
| `200 OK` | Success | Standard successful `GET` |
| `201 Created` | Resource created | Successful `POST` |
| `204 No Content` | Success, nothing to return | Successful `DELETE` |
| `400 Bad Request` | Client sent invalid data | Missing/invalid fields |
| `401 Unauthorized` | Not authenticated | Missing/invalid token |
| `403 Forbidden` | Authenticated but not allowed | Permission denied |
| `404 Not Found` | Resource doesn’t exist | Bad endpoint/resource ID |
| `429 Too Many Requests` | Rate limited | Too many requests |
| `500 Internal Server Error` | Server bug | Unexpected error |
| `503 Service Unavailable` | Server can’t handle it | Maintenance/overload |
### A Minimal, Secure API Endpoint Structure
Every endpoint should follow a consistent structure. Example response envelope:
“`json
{
  “success”: true,
  “data”: {
    “id”: 42,
    “name”: “Win Tiger Dashboard”
  },
  “meta”: {
    “timestamp”: “2026-08-08T22:00:00Z”
  }
}
“`
Error responses:
“`json
{
  “success”: false,
  “error”: {
    “code”: “INVALID_INPUT”,
    “message”: “email is required”
  }
}
“`
### Building Your First API
#### Option A — Node.js with Express
“`bash
# On your Ubuntu server
mkdir /var/www/api && cd /var/www/api
npm init -y
npm install express mysql2 dotenv cors helmet rate-limiter-flexible
“`
**`server.js`:**
“`js
const express = require(‘express’);
const mysql = require(‘mysql2/promise’);
const helmet = require(‘helmet’);
const cors = require(‘cors’);
const dotenv = require(‘dotenv’);
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// Security middleware
app.use(helmet());                     // Sets secure HTTP headers
app.use(express.json({ limit: ‘100kb’ })); // JSON body, size-limited
// CORS — critical for Cloudflare Pages origin (see Part 4)
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS
    ? process.env.ALLOWED_ORIGINS.split(‘,’)
    : [‘http://localhost:3000’],      // NEVER use ‘*’ if you send credentials
  methods: [‘GET’, ‘POST’, ‘PUT’, ‘PATCH’, ‘DELETE’],
  allowedHeaders: [‘Content-Type’, ‘Authorization’],
  maxAge: 86400
}));
// Database pool (connection pooling, secure usage)
const db = mysql.createPool({
  host: process.env.DB_HOST || ‘127.0.0.1’,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  timezone: ‘Z’
});
// Health check
app.get(‘/api/v1/health’, (req, res) => {
  res.json({ success: true, data: { status: ‘ok’ } });
});
// GET — with SQL injection protection via parameterized queries
app.get(‘/api/v1/users’, async (req, res) => {
  try {
    const [rows] = await db.query(
      ‘SELECT id, name, email FROM users ORDER BY id DESC LIMIT 100’
    );
    res.json({ success: true, data: rows });
  } catch (err) {
    console.error(err);
    res.status(500).json({ success: false, error: { code: ‘DB_ERROR’, message: ‘Database error’ } });
  }
});
// POST — input validation + parameterized insert
app.post(‘/api/v1/users’, async (req, res) => {
  const { name, email } = req.body || {};
  if (!name || typeof name !== ‘string’ || name.length > 100) {
    return res.status(400).json({
      success: false,
      error: { code: ‘INVALID_INPUT’, message: ‘name is required (max 100 chars)’ }
    });
  }
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return res.status(400).json({
      success: false,
      error: { code: ‘INVALID_INPUT’, message: ‘valid email is required’ }
    });
  }
  try {
    const [result] = await db.query(
      ‘INSERT INTO users (name, email) VALUES (?, ?)’,
      [name, email]            // <- NEVER string-concatenate SQL
    );
    res.status(201).json({
      success: true,
      data: { id: result.insertId, name, email }
    });
  } catch (err) {
    if (err.code === ‘ER_DUP_ENTRY’) {
      return res.status(409).json({
        success: false,
        error: { code: ‘DUPLICATE’, message: ’email already exists’ }
      });
    }
    console.error(err);
    res.status(500).json({ success: false, error: { code: ‘DB_ERROR’, message: ‘Database error’ } });
  }
});
// Start server listening ONLY on localhost — Apache2 proxies to it
app.listen(PORT, ‘127.0.0.1’, () => {
  console.log(`API listening on 127.0.0.1:${PORT}`);
});
“`
**`.env`** (never commit to git — create `.env.example` instead):
“`ini
PORT=3000
DB_HOST=127.0.0.1
DB_USER=api_user
DB_PASSWORD=CHANGE_ME_STRONG_PASSWORD
DB_NAME=myapp
ALLOWED_ORIGINS=https://dashboard.yourdomain.com
“`
> **Key pattern:** The app listens on `127.0.0.1` only. Apache2 (port 443) is the only thing exposed to the internet. This is called a **reverse proxy** and keeps your app away from direct internet exposure.
**Run with a process manager** (restarts on crash, starts at boot):
“`bash
sudo npm install -g pm2
pm2 start server.js –name api
pm2 save
pm2 startup
“`
#### Option B — Python with Flask
“`python
# app.py
from flask import Flask, request, jsonify
from flask_mysqldb import MySQL  # or use SQLAlchemy / psycopg2 for PostgreSQL
import os
app = Flask(__name__)
app.config[“MYSQL_HOST”] = os.getenv(“DB_HOST”, “127.0.0.1”)
app.config[“MYSQL_USER”] = os.getenv(“DB_USER”)
app.config[“MYSQL_PASSWORD”] = os.getenv(“DB_PASSWORD”)
app.config[“MYSQL_DB”] = os.getenv(“DB_NAME”)
mysql = MySQL(app)
@app.route(“/api/v1/users”, methods=[“GET”])
def get_users():
    cur = mysql.connection.cursor()
    # Parameterized query — never f-strings with user input!
    cur.execute(“SELECT id, name, email FROM users ORDER BY id DESC LIMIT 100”)
    rows = cur.fetchall()
    cur.close()
    return jsonify({“success”: True, “data”: rows})
@app.route(“/api/v1/users”, methods=[“POST”])
def create_user():
    data = request.get_json(silent=True) or {}
    name, email = data.get(“name”, “”).strip(), data.get(“email”, “”).strip()
    if not name or not email:
        return jsonify({“success”: False, “error”: {“code”: “INVALID_INPUT”,
                                                    “message”: “name and email required”}}), 400
    cur = mysql.connection.cursor()
    cur.execute(
        “INSERT INTO users (name, email) VALUES (%s, %s)”,  # %s placeholders!
        (name, email)
    )
    mysql.connection.commit()
    new_id = cur.lastrowid
    cur.close()
    return jsonify({“success”: True, “data”: {“id”: new_id, “name”: name, “email”: email}}), 201
if __name__ == “__main__”:
    app.run(host=”127.0.0.1″, port=3000)
“`
Run with Gunicorn:
“`bash
gunicorn -w 4 -b 127.0.0.1:3000 app:app
“`
#### Option C — PHP with PDO (common on Apache/Ubuntu LAMP)
“`php
<?php
// api.php — includes database + endpoint logic
declare(strict_types=1);
$pdo = new PDO(
    ‘mysql:host=127.0.0.1;dbname=myapp;charset=utf8mb4’,
    getenv(‘DB_USER’),
    getenv(‘DB_PASSWORD’),
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,   // real prepared statements
    ]
);
header(‘Content-Type: application/json’);
$method = $_SERVER[‘REQUEST_METHOD’];
if ($method === ‘GET’) {
    $stmt = $pdo->query(‘SELECT id, name, email FROM users ORDER BY id DESC LIMIT 100’);
    echo json_encode([‘success’ => true, ‘data’ => $stmt->fetchAll()]);
} elseif ($method === ‘POST’) {
    $input = json_decode(file_get_contents(‘php://input’), true) ?? [];
    $name  = trim($input[‘name’]  ?? ”);
    $email = trim($input[’email’] ?? ”);
    if ($name === ” || $email === ” || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
        http_response_code(400);
        echo json_encode([‘success’ => false, ‘error’ => [‘code’ => ‘INVALID_INPUT’,
                                                          ‘message’ => ‘name and a valid email required’]]);
        exit;
    }
    // Prepared statements kill SQL injection
    $stmt = $pdo->prepare(‘INSERT INTO users (name, email) VALUES (?, ?)’);
    $stmt->execute([$name, $email]);
    http_response_code(201);
    echo json_encode([‘success’ => true, ‘data’ => [‘id’ => $pdo->lastInsertId(), ‘name’ => $name, ’email’ => $email]]);
} else {
    http_response_code(405);
    echo json_encode([‘success’ => false, ‘error’ => [‘code’ => ‘METHOD_NOT_ALLOWED’, ‘message’ => ‘Unsupported method’]]);
}
“`
### Consuming the API from JavaScript
This is how your Cloudflare Pages-hosted dashboard talks to your API:
“`js
const API_BASE = ‘https://api.yourdomain.com/api/v1’; // your API on Cloudflare DNS
// Or if using one domain with subpaths: `${window.location.origin}/api/v1`
// GET request
async function fetchUsers() {
  try {
    const res = await fetch(`${API_BASE}/users`, {
      headers: { ‘Authorization’: `Bearer ${getToken()}` } // token, not API key in URL
    });
    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      throw new Error(err.error?.message || `HTTP ${res.status}`);
    }
    const { data } = await res.json();
    renderUsers(data);
  } catch (error) {
    console.error(‘API call failed:’, error.message);
    showToast(‘Failed to load users’);
  }
}
// POST request
async function createUser(name, email) {
  const res = await fetch(`${API_BASE}/users`, {
    method: ‘POST’,
    headers: {
      ‘Content-Type’: ‘application/json’,
      ‘Authorization’: `Bearer ${getToken()}`
    },
    body: JSON.stringify({ name, email })
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.error?.message || ‘Create failed’);
  }
  return res.json().then(r => r.data);
}
“`
**Rules for client-side API calls:**
– Never put secrets (DB passwords, private API keys) in frontend JS — browsers expose it all.
– Store the user’s token in `localStorage`/cookies; **never** the API’s own secrets.
– Always attach `Authorization` header, never credentials in the URL query string (they end up in logs).
—
## 3. Part 2 — Apache2 Reverse Proxy
Your public API URL (e.g., `api.yourdomain.com`) points at your Ubuntu server. Apache2 accepts HTTPS requests from the internet and forwards them to your API app on `127.0.0.1:3000`.
### Install & Enable Modules
“`bash
sudo apt update
sudo apt install -y apache2 certbot python3-certbot-apache
sudo a2enmod proxy proxy_http headers ssl rewrite
sudo systemctl restart apache2
“`
### Virtual Host Configuration
Create `/etc/apache2/sites-available/api.yourdomain.com.conf`:
“`apache
<VirtualHost *:80>
    ServerName api.yourdomain.com
    # Redirect all HTTP to HTTPS
    RewriteEngine On
    RewriteCond %{HTTPS} off
    RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R=301,L]
    # (optional) Redirect bare domain traffic if applicable
    # ServerAlias yourdomain.com
    # RewriteCond %{HTTP_HOST} !^api\. [NC]
    # RewriteRule ^ https://yourdomain.com%{REQUEST_URI} [R=301,L]
</VirtualHost>
<VirtualHost *:443>
    ServerName api.yourdomain.com
    SSLEngine on
    SSLCertificateFile      /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem
    SSLCertificateKeyFile   /etc/letsencrypt/live/api.yourdomain.com/privkey.pem
    # Hardening: only modern TLS
    SSLProtocol             all -SSLv3 -TLSv1 -TLSv1.1
    SSLCipherSuite          ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
    SSLHonorCipherOrder     off
    # —- SECURITY HEADERS (see section below) —-
    Header always set Strict-Transport-Security “max-age=31536000; includeSubDomains; preload”
    Header always set X-Content-Type-Options “nosniff”
    Header always set X-Frame-Options “DENY”
    Header always set Referrer-Policy “strict-origin-when-cross-origin”
    Header always set Permissions-Policy “geolocation=(), camera=(), microphone=()”
    # Content-Security-Policy: adjust to your frontend needs
    Header always set Content-Security-Policy “default-src ‘self’; connect-src ‘self’ https://api.yourdomain.com; frame-ancestors ‘none'”
    # —- REVERSE PROXY TO YOUR API —-
    ProxyPreserveHost On
    ProxyRequests Off                     # IMPORTANT: prevents open proxy abuse
    # Tell upstream app that the visitor came over HTTPS
    RequestHeader set X-Forwarded-Proto “https”
    RequestHeader set X-Forwarded-For “%{REMOTE_ADDR}s”
    # Health endpoint can bypass auth if you have basic auth elsewhere
    ProxyPass /api/v1/health !
    <Location /api/v1/health>
        Require all granted
    </Location>
    # Route API traffic to the local app (adjust port per your app)
    ProxyPass        /api/ http://127.0.0.1:3000/
    ProxyPassReverse /api/ http://127.0.0.1:3000/
    # —- OPTIONAL: Basic Auth in front of everything (extra gate) —-
    # <Location /api/>
    #     AuthType Basic
    #     AuthName “API Access”
    #     AuthUserFile /etc/apache2/.htpasswd-api
    #     Require valid-user
    # </Location>
    # Create password file with:  sudo htpasswd -c /etc/apache2/.htpasswd-api you
    ErrorLog  ${APACHE_LOG_DIR}/api-error.log
    CustomLog ${APACHE_LOG_DIR}/api-access.log combined
</VirtualHost>
“`
Enable and test:
“`bash
sudo a2ensite api.yourdomain.com.conf
sudo apache2ctl -t                 # syntax check
sudo systemctl reload apache2
“`
### SSL with Let’s Encrypt
“`bash
sudo certbot –apache -d api.yourdomain.com
# Choose redirect when asked. Certbot auto-renews via a systemd timer.
sudo certbot renew –dry-run      # verify auto-renewal works
“`
> **With Cloudflare proxied DNS (orange cloud), two SSL options exist:**
> – **Flexible** — Cloudflare→browser encrypted, Cloudflare→origin not. **Avoid this** for APIs.
> – **Full (Strict)** — encrypted end-to-end with a valid origin cert. **Use this.** Then run certbot for the origin cert too (or use a [Cloudflare Origin CA certificate](https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/)).
> – Set SSL/TLS mode to **Full (Strict)** in Cloudflare dashboard → SSL/TLS → Overview.
### Security Headers
| Header | Purpose |
|—|—|
| `Strict-Transport-Security` | Forces browsers to use HTTPS only |
| `X-Content-Type-Options: nosniff` | Prevents MIME-sniffing attacks |
| `X-Frame-Options: DENY` | Blocks clickjacking (iframes) |
| `Content-Security-Policy` | Restricts what resources can load/connect |
| `Referrer-Policy` | Limits what URL info is sent to other sites |
| `Permissions-Policy` | Disables optional browser features |
| `Cache-Control: no-store` | Keep sensitive API responses out of caches |
For any response body, also consider:
“`apache
Header always set Cache-Control “no-store, max-age=0”
“`
—
## 4. Part 3 — Database Connection & Security
### Best Practices for SQL
1. **Always use parameterized queries / prepared statements.** Never build SQL by concatenating user input. This is the #1 defense against SQL injection.
   “`js
   // ❌ DANGEROUS — never do this:
   // db.query(`SELECT * FROM users WHERE email = ‘${email}’`)
   // ✅ SAFE — parameterized:
   db.query(‘SELECT * FROM users WHERE email = ?’, [email])
   “`
2. **Validate and sanitize input** before it reaches the database (type, length, format, whitelist values where possible).
3. **Limit result sets** with `LIMIT`; never dump entire tables.
4. **Use connection pooling** (fewer costly handshakes, controlled concurrency).
5. **Don’t expose DB errors to clients** — log internally, return generic 500s.
6. **Encrypt credentials** — store DB passwords in `.env` files with `chmod 600`, or use a secrets manager (e.g., HashiCorp Vault, AWS Secrets Manager, or `systemd` `EnvironmentFile=`).
### Secure Connection Examples
#### MySQL/MariaDB via Node.js (mysql2)
See the pool setup in the [Node.js example](#option-a–nodejs-with-express) — uses env vars, TLS option available:
“`js
const db = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  ssl: process.env.DB_SSL === ‘true’ ? { rejectUnauthorized: true } : undefined
});
“`
#### PostgreSQL via Node.js (`pg`)
“`js
const { Pool } = require(‘pg’);
const pool = new Pool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  ssl: process.env.DB_SSL === ‘true’ ? { rejectUnauthorized: true } : undefined
});
// Parameterized query ($1, $2 placeholders)
const { rows } = await pool.query(
  ‘SELECT id, name, email FROM users WHERE email = $1’,
  [email]
);
“`
#### Python (psycopg2)
“`python
import psycopg2
conn = psycopg2.connect(
    host=os.getenv(“DB_HOST”),
    user=os.getenv(“DB_USER”),
    password=os.getenv(“DB_PASSWORD”),
    dbname=os.getenv(“DB_NAME”),
    sslmode=”require” if os.getenv(“DB_SSL”) == “true” else “prefer”,
)
cur = conn.cursor()
cur.execute(“SELECT id, name, email FROM users WHERE email = %s”, (email,))
“`
### Database User Management & Least Privilege
Create a **dedicated, limited** DB user for your API — never run the app as `root`:
“`sql
— MySQL / MariaDB
CREATE USER ‘api_user’@’127.0.0.1’ IDENTIFIED BY ‘A_Strong_P@ssw0rd_Here’;
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO ‘api_user’@’127.0.0.1’;
FLUSH PRIVILEGES;
“`
“`sql
— PostgreSQL
CREATE ROLE api_user LOGIN PASSWORD ‘A_Strong_P@ssw0rd_Here’;
GRANT CONNECT ON DATABASE myapp TO api_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO api_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO api_user;
“`
**Rules:**
– Grant only `SELECT/INSERT/UPDATE/DELETE` — **no** `DROP`, `ALTER`, or `GRANT OPTION`.
– Bind the user to `127.0.0.1` / `localhost` so it can’t connect remotely.
– If your DB and API are on the same server, bind MySQL/PostgreSQL to `127.0.0.1` only (not `0.0.0.0`).
– Rotate credentials periodically; invalidate immediately if leaked.
**Firewall (UFW):**
“`bash
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
# Database port (3306/5432) is NOT opened to the internet — only localhost
“`
### Backups
“`bash
# MySQL/MariaDB — daily backup, keep 7 days
mkdir -p /var/backups/mysql
sudo tee /etc/cron.d/mysql-backup <<‘EOF’
0 2 * * * root mysqldump –single-transaction -u backup_user -pREDACTED myapp | gzip > /var/backups/mysql/myapp-$(date +\%F).sql.gz && find /var/backups/mysql -name ‘*.sql.gz’ -mtime +7 -delete
EOF
“`
“`bash
# PostgreSQL
0 2 * * * root pg_dump -U backup_user myapp | gzip > /var/backups/pgsql/myapp-$(date +\%F).sql.gz && find /var/backups/pgsql -name ‘*.sql.gz’ -mtime +7 -delete
“`
> Also back up your `.env`, Apache configs, and API code. **Test restoring from backups periodically** — an untested backup is not a backup.
—
## 5. Part 4 — Cloudflare Setup
### DNS Configuration
In **Cloudflare Dashboard → your domain → DNS → Records**, add:
| Type | Name | Content | Proxy |
|—|—|—|—|
| `A` | `api` | `YOUR_SERVER_IP` (your Ubuntu server) | Proxied (orange cloud) ✅ |
| `CNAME` | `yourdomain.com` (or `www`) | `your-project.pages.dev` | Proxied ✅ |
| `CNAME` | `dashboard` (subdomain) | `your-project.pages.dev` | Proxied ✅ |
– **Proxied (orange cloud)** gives you Cloudflare’s CDN, WAF, and hides your origin IP from DNS lookups.
– For the API subdomain, the record points to your server IP; all 80/443 traffic to that name flows through Cloudflare.
### Cloudflare Pages for Your Frontend
1. **Set up the project:**
   – Dashboard → Workers & Pages → Create → Pages → Connect to a Git repo (or direct upload).
   – Build command: e.g. `npm run build` (or none for plain HTML/JS).
   – Output directory: e.g. `dist` (or the folder containing your `index.html`).
2. **Add a custom domain:**
   – Pages project → Custom domains → Add → `dashboard.yourdomain.com` (or `yourdomain.com`).
   – Cloudflare creates the DNS record automatically (keep it proxied).
3. **Environment variables in Pages** (Settings → Environment variables): set `API_BASE_URL=https://api.yourdomain.com` at build time and inject it into your JS:
   “`js
   const API_BASE_URL = window.API_BASE_URL || ‘https://api.yourdomain.com’;
   “`
   Or use a build-time constant. Never put secrets here — these are readable by anyone.
4. **(Optional) Redirects** — add a `_redirects` file to route any legacy paths:
   “`
   /old-path  /new-path  301
   “`
### Connecting the Frontend to the API (CORS)
Because your frontend is on `dashboard.yourdomain.com` (Cloudflare Pages) and your API is on `api.yourdomain.com`, the browser enforces **CORS**. The API must explicitly allow the frontend origin:
“`js
// Express example (from Part 1)
app.use(cors({
  origin: [‘https://dashboard.yourdomain.com’, ‘https://yourdomain.com’],
  methods: [‘GET’, ‘POST’, ‘PUT’, ‘PATCH’, ‘DELETE’, ‘OPTIONS’],
  allowedHeaders: [‘Content-Type’, ‘Authorization’],
  credentials: false,        // true only if you send cookies
  maxAge: 86400
}));
“`
**Apache-level CORS alternative** (if you can’t change app code):
“`apache
<IfModule mod_headers.c>
    Header always set Access-Control-Allow-Origin “https://dashboard.yourdomain.com”
    Header always set Access-Control-Allow-Methods “GET, POST, PUT, PATCH, DELETE, OPTIONS”
    Header always set Access-Control-Allow-Headers “Content-Type, Authorization”
    Header always set Access-Control-Max-Age “86400”
</IfModule>
“`
> **CORS is not a security boundary** — it’s a browser policy. Anyone can still call your API with `curl`. Real protection comes from authentication (tokens), rate limiting, and validation.
### Protecting Your Origin Server
Even with Cloudflare in front, an attacker can discover your origin IP (e.g., via old DNS records, email headers, or certificate transparency). Mitigations:
1. **Firewall: only allow Cloudflare IPs on 80/443.**
   Cloudflare publishes [IP ranges here](https://www.cloudflare.com/ips/). Example (cron-updated):
   “`bash
   ## /etc/cron.d/cloudflare-ips
   # (fetch ranges and rebuild iptables/ufw allow rules for 80 & 443)
   “`
   With UFW you’d add `allow from <cf-ip>/<prefix> to any port 80,443` for each range, then `deny 80/443` from everything else. **Allow 22 (SSH) only from your own IPs.**
2. **Cloudflare Authenticated Origin Pulls** (Dashboard → SSL/TLS → Origin Server → Authenticated Origin Pulls → On; then configure your server to require the Cloudflare client cert). This is the strongest option — requests to your origin must present Cloudflare’s client certificate.
3. **Cloudflare WAF Rules** (Dashboard → Security → WAF):
   – Block obvious bots / malicious IPs.
   – Add a Rate Limiting rule for your API, e.g. **120 requests / minute / IP** with a 429 challenge.
4. **Block requests that don’t come through Cloudflare** in Apache (when using Origin CA paths):
   “`apache
   # Only allow Cloudflare IP ranges — keeps your origin unreachable directly
   <Location />
       Require ip 173.245.48.0/20
       # … (add all ranges from https://www.cloudflare.com/ips/)
   </Location>
   “`
5. **Turn off direct API access on the bare domain** and keep `api.` subdomain proxied.
6. **Hide your origin IP:** don’t put it in SPF records, don’t run services that leak it (like email with raw IP headers), and use Cloudflare’s proxy everywhere.
—
## 6. Part 5 — API Security Checklist
### Authentication & Authorization
– [ ] Use token-based auth (JWT / OAuth2 / API keys) — never plaintext passwords in requests
– [ ] JWT: sign with strong key (≥256-bit), set short `exp` (e.g., 15 min–24 h), validate `iss`/`aud`, prefer `https` only
– [ ] Store secrets server-side only (`.env` with `chmod 600`, never in git or frontend code)
– [ ] Implement per-user permissions (don’t trust client-supplied roles)
– [ ] Invalidate tokens on logout / password change
### Transport & Headers
– [ ] HTTPS everywhere — Cloudflare **Full (Strict)** + Let’s Encrypt origin cert
– [ ] HTTP → HTTPS redirect (301) for all traffic
– [ ] HSTS with `includeSubDomains; preload`
– [ ] Security headers set (CSP, nosniff, frame-deny, referrer policy)
– [ ] No mixed content — all API calls from the dashboard use `https://`
### Application Level
– [ ] Parameterized SQL queries everywhere (no string-built SQL)
– [ ] Input validation: type, length, format, allowed values
– [ ] Response body size limits (e.g., `express.json({ limit: ‘100kb’ })`)
– [ ] Never return stack traces / DB errors to clients
– [ ] Disable verbose error pages (`ServerTokens Prod` in Apache)
– [ ] Rate limiting per IP + per token (Cloudflare WAF or `mod_evasive`)
– [ ] Log API calls (at least: timestamp, IP, method, path, status, user) — without logging secrets/tokens
### Infrastructure & Ops
– [ ] API binds to `127.0.0.1` only — internet exposure only via Apache2
– [ ] `ProxyRequests Off` (prevents open proxy)
– [ ] Firewall (UFW): 22/80/443 only; DB port not exposed
– [ ] DB user is least-privilege, bound to localhost, password rotated
– [ ] Backups automated and **restore-tested**
– [ ] Automatic security updates: `sudo apt install unattended-upgrades` (configured)
– [ ] CI/CD doesn’t bake secrets into frontend bundles
– [ ] Cloudflare: proxied DNS, WAF on, rate limiting on the API hostname, origin protected by firewall/Authenticated Origin Pulls
### Cloudflare-Specific
– [ ] SSL mode = **Full (Strict)**
– [ ] All DNS records proxied (orange cloud)
– [ ] WAF managed rules enabled
– [ ] Rate limiting rule for `api.*` endpoints
– [ ] Origin server firewall only accepts Cloudflare IPs on 80/443
– [ ] (Recommended) Authenticated Origin Pulls enabled
– [ ] Cloudflare Pages env vars contain **no secrets**
—
## 7. Troubleshooting Common Issues
| Symptom | Likely Cause | Fix |
|—|—|—|
| **502 Bad Gateway** | Apache can’t reach your API app | App crashed / not listening on `127.0.0.1:3000`. `sudo journalctl -u api -n 50` or `pm2 logs`. Verify: `curl http://127.0.0.1:3000/api/v1/health` |
| **CORS error in dashboard console** | API doesn’t allow the Pages origin | Add your Pages domain(s) to `Access-Control-Allow-Origin` in the API or Apache |
| **Mixed content blocked** | Frontend calling `http://api…` | Use `https://` everywhere; check `X-Forwarded-Proto` handling |
| **Too many redirects / SSL loop** | SSL mode mismatch | Use **Full (Strict)** in Cloudflare; don’t force redirect inside the proxy if origin is HTTP and Cloudflare handles it |
| **403 Forbidden** | WAF/rate limit blocked you, or IP allowlist too strict | Check Cloudflare Security events; allowlist own IP; relax Origin CA config |
| **Requests work with curl but not browser** | CORS missing, or token not sent | Add CORS headers; browser sends `Origin` header — verify it’s in your allowlist |
| **`ERR_CERT_AUTHORITY_INVALID` with cloudflare cert on origin** | Using Flexible mode or wrong cert at origin | Use Full (Strict), origin cert valid for the hostname |
| **API sees HTTP even though site is HTTPS** | `X-Forwarded-Proto` not passed | Add `RequestHeader set X-Forwarded-Proto “https”` in the Apache vhost |
| **Rate limit triggered by your own dashboard** | Same IP for many users behind NAT | Tune rate limit; consider rate limiting by token instead of IP |
—
## 8. Quick Reference — Recommended Config Snippets
### Apache2 — hardened reverse proxy vhost summary
“`apache
<VirtualHost *:443>
    ServerName api.yourdomain.com
    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/api.yourdomain.com/privkey.pem
    SSLProtocol           all -SSLv3 -TLSv1 -TLSv1.1
    Header always set Strict-Transport-Security “max-age=31536000; includeSubDomains; preload”
    Header always set X-Content-Type-Options “nosniff”
    Header always set X-Frame-Options “DENY”
    Header always set Referrer-Policy “strict-origin-when-cross-origin”
    Header always set Cache-Control “no-store”
    ProxyPreserveHost On
    ProxyRequests Off
    RequestHeader set X-Forwarded-Proto “https”
    ProxyPass        /api/ http://127.0.0.1:3000/
    ProxyPassReverse /api/ http://127.0.0.1:3000/
</VirtualHost>
“`
### Node.js Express — CORS + security middleware summary
“`js
app.use(helmet());
app.use(express.json({ limit: ‘100kb’ }));
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS.split(‘,’),
  methods: [‘GET’,’POST’,’PUT’,’PATCH’,’DELETE’,’OPTIONS’],
  allowedHeaders: [‘Content-Type’,’Authorization’],
  maxAge: 86400
}));
“`
### MySQL — least-privilege app user
“`sql
CREATE USER ‘api_user’@’127.0.0.1’ IDENTIFIED BY ‘A_Strong_P@ssw0rd_Here’;
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO ‘api_user’@’127.0.0.1’;
FLUSH PRIVILEGES;
“`
### Cloudflare — recommended dashboard settings
1. SSL/TLS → **Full (Strict)**
2. SSL/TLS → Edge Certificates → **Always Use HTTPS: On**, HSTS: On
3. Security → WAF → Managed Rules: **On**
4. Security → WAF → Rate Limiting Rules: API hostname → **120 req/min/IP → Block (or Challenge)**
5. DNS → all records **proxied**
6. SSL/TLS → Origin Server → **Authenticated Origin Pulls: On** (with server client-cert config)
—
*Document created for a stack: Ubuntu server + Apache2 reverse proxy + SQL database + Cloudflare DNS + Cloudflare Pages frontend.*