Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 125 additions & 123 deletions nginx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,132 +2,76 @@

## Overview

This folder contains the Nginx virtual host configuration files for **VM1** (`rythmify-app-vm`, IP `20.196.3.253`). These files are not loaded automatically — they must be deployed to the server manually (see [Deployment](#deployment)).
This folder contains the Nginx virtual host configuration files for **VM1** (`rythmify-app-vm`, IP `20.196.3.253`).

| File | Domain | Purpose |
|---|---|---|
| `rythmify-back.duckdns.org.conf` | `rythmify-back.duckdns.org` | Backend API + Socket.IO proxy, GeoIP2 country injection, HTTPS, Grafana proxy, Nginx stub_status |
| `rythmify.duckdns.org.conf` | `rythmify.duckdns.org` | Frontend Vite dev server proxy, HTTPS |

The backend config contains three server blocks:
- **HTTP → HTTPS redirect** on port 80
- **HTTPS backend** on port 443 — proxies `/health`, `/grafana/`, and all API + WebSocket traffic to `localhost:8080` / `localhost:3000`
- **stub_status** on `172.17.0.1:8081` — scraped by `nginx_exporter` inside Docker

The `geoip2` directive (above all `server {}` blocks, in the `http {}` context) resolves every client IP to a country code and injects it as `X-Country-Code` on all proxied requests to the backend.
| `rythmify-back.duckdns.org.conf` | `rythmify-back.duckdns.org` | Backend API + Socket.IO WebSocket proxy, GeoIP2 country injection, Grafana proxy, HTTPS |
| `rythmify.duckdns.org.conf` | `rythmify.duckdns.org` | Frontend static files (React SPA), HTTPS |

---

## Prerequisites
## Domain Split

Install the following packages on VM1:
### rythmify.duckdns.org — Frontend

```bash
# Nginx + GeoIP2 dynamic module
sudo apt update
sudo apt install -y nginx libnginx-mod-http-geoip2

# MaxMind GeoIP database updater
sudo apt install -y geoipupdate
Serves the pre-built React SPA from `/var/www/rythmify/`. No backend proxying on this domain. The frontend app calls `https://rythmify-back.duckdns.org/api/v1/...` directly — all API traffic goes to the backend domain.

# Certbot for SSL certificate issuance and renewal
sudo apt install -y certbot python3-certbot-nginx
```
### rythmify-back.duckdns.org — Backend

`libnginx-mod-http-geoip2` drops a module config file into `/etc/nginx/modules-enabled/` that loads the GeoIP2 module automatically — no manual `load_module` directive is needed in `nginx.conf`.
Reverse proxy to the Node.js backend container on `localhost:8080` and Grafana on `localhost:3000`. All API, WebSocket, and monitoring traffic enters through this domain.

---

## GeoIP Setup

The backend config resolves client IPs using MaxMind's free **GeoLite2-Country** database. A free MaxMind account is required.

**1. Create a MaxMind account**

Sign up at https://www.maxmind.com/en/geolite2/signup and generate a License Key under **My Account → Manage License Keys**.

**2. Configure `/etc/GeoIP.conf`**

```ini
AccountID YOUR_ACCOUNT_ID
LicenseKey YOUR_LICENSE_KEY
EditionIDs GeoLite2-Country
DatabaseDirectory /var/lib/GeoIP
```

Replace `YOUR_ACCOUNT_ID` and `YOUR_LICENSE_KEY` with your actual values.

**3. Download the database**

```bash
sudo geoipupdate
```

**4. Verify the file exists**

```bash
ls -lh /var/lib/GeoIP/GeoLite2-Country.mmdb
```
## Location Blocks

Expected: a file of approximately 6–7 MB. If the file is missing, Nginx will fail to start with an error such as:
### rythmify-back.duckdns.org — HTTPS server (port 443)

```
geoip2: unable to open database "/var/lib/GeoIP/GeoLite2-Country.mmdb"
```

---

## Deployment

**1. Copy configs to `sites-available`**

```bash
sudo cp rythmify-back.duckdns.org.conf /etc/nginx/sites-available/
sudo cp rythmify.duckdns.org.conf /etc/nginx/sites-available/
```

**2. Enable the sites**

```bash
sudo ln -s /etc/nginx/sites-available/rythmify-back.duckdns.org.conf \
/etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/rythmify.duckdns.org.conf \
/etc/nginx/sites-enabled/
```
| Location | Proxy target | Purpose |
|---|---|---|
| `/health` | `http://localhost:8080` | Liveness probe used by Blackbox exporter — no WebSocket headers |
| `/api/` | `http://localhost:8080` | Explicit REST API path — full header set including WebSocket upgrade (see note) |
| `/grafana/` | `http://localhost:3000/` | Grafana dashboard — proxied internally, not exposed on its own port |
| `/` | `http://localhost:8080` | All API + Socket.IO WebSocket traffic — full header set including X-Country-Code |

Disable the default Nginx site if it is enabled (it conflicts with the new configs):
**Why the explicit `/api/` block?**

```bash
sudo rm -f /etc/nginx/sites-enabled/default
```
Nginx uses longest-prefix matching. Declaring `/api/` explicitly makes routing intent clear and allows per-path tuning (rate limits, timeouts) in future without touching the catch-all `/` block. WebSocket upgrade headers are kept on `/api/` so Socket.IO clients using a custom path (e.g. `/api/socket.io/`) are not broken.

**3. Test the configuration**
**Required headers on `/` and `/api/`:**

```bash
sudo nginx -t
```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Country-Code $geoip2_data_country_code;
proxy_cache_bypass $http_upgrade;
```

Both lines must read `syntax is ok` and `test is successful`. Fix any reported errors before continuing.
### rythmify.duckdns.org — HTTPS server (port 443)

**4. Reload Nginx**
| Location | Behaviour | Purpose |
|---|---|---|
| `/` | `try_files $uri $uri/ /index.html` | React SPA fallback — unknown paths serve `index.html` so client-side routing works |

```bash
sudo systemctl reload nginx
```
No proxy blocks. No `/nginx_status`. This domain is static-files-only.

---

## X-Country-Code Header
## GeoIP2

Nginx resolves each request's client IP against the **MaxMind GeoLite2-Country** database using the `ngx_http_geoip2` module. The `geoip2` block at the top of `rythmify-back.duckdns.org.conf` declares the variable:
The `geoip2` directive appears at the **http context level** (above all `server {}` blocks) in `rythmify-back.duckdns.org.conf`:

```nginx
geoip2 /var/lib/GeoIP/GeoLite2-Country.mmdb {
$geoip2_data_country_code country iso_code;
}
```

That variable is then injected as a request header before the request is forwarded to the backend:
This resolves the client IP to an ISO 3166-1 alpha-2 country code. The variable is injected as a request header on all proxied backend requests:

```nginx
proxy_set_header X-Country-Code $geoip2_data_country_code;
Expand All @@ -139,36 +83,35 @@ The backend reads it as:
const country = req.headers['x-country-code']; // e.g. "EG", "SA", "US"
```

The value is an **ISO 3166-1 alpha-2** two-letter code. Examples: `EG` (Egypt), `SA` (Saudi Arabia), `US` (United States), `GB` (United Kingdom). The variable resolves to an empty string for private or unroutable IP addresses (e.g. `127.0.0.1`, `10.x.x.x`, `172.16.x.x`).
The value is empty for private or unroutable IPs (`127.0.0.1`, `10.x.x.x`, `172.16.x.x`). The header cannot be spoofed — Nginx overwrites any client-supplied value before forwarding.

Because the header is set by Nginx at the proxy layer, it **cannot be spoofed** by clients — any `X-Country-Code` value sent in the original request is overwritten by Nginx before it reaches the backend.
**Setup:**

---
```bash
# Install module and updater
sudo apt install -y libnginx-mod-http-geoip2 geoipupdate

## Grafana Proxy
# Configure /etc/GeoIP.conf with your MaxMind account credentials
# Sign up free at https://www.maxmind.com/en/geolite2/signup
AccountID YOUR_ACCOUNT_ID
LicenseKey YOUR_LICENSE_KEY
EditionIDs GeoLite2-Country
DatabaseDirectory /var/lib/GeoIP

Grafana runs inside Docker on port `3000`. It is not exposed publicly. Instead, the backend server block proxies `/grafana/` to `http://localhost:3000/`:
# Download the database
sudo geoipupdate

```nginx
location /grafana/ {
proxy_pass http://localhost:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Verify (expect ~6-7 MB file)
ls -lh /var/lib/GeoIP/GeoLite2-Country.mmdb
```

Access Grafana at: **https://rythmify-back.duckdns.org/grafana/**

No SSH tunnel or port forwarding is needed.
`libnginx-mod-http-geoip2` auto-loads the module via `/etc/nginx/modules-enabled/` — no manual `load_module` directive is needed in `nginx.conf`.

---

## Nginx stub_status
## Nginx stub_status (Internal — 172.17.0.1:8081)

`nginx_exporter` (in `docker-compose.monitoring.yml`) scrapes Nginx connection metrics from a dedicated `stub_status` endpoint. The endpoint is defined in `rythmify-back.duckdns.org.conf` as a server block bound to the Docker bridge host IP:
`nginx_exporter` in `docker-compose.monitoring.yml` scrapes Nginx connection metrics from a dedicated internal endpoint defined in `rythmify-back.duckdns.org.conf`:

```nginx
server {
Expand All @@ -177,9 +120,7 @@ server {

location /nginx_status {
stub_status;
allow 127.0.0.1;
allow 172.16.0.0/12;
deny all;
allow all;
}

location / {
Expand All @@ -188,7 +129,7 @@ server {
}
```

`172.17.0.1` is the Docker bridge host IP — it is reachable from Docker containers but not from the public internet or from the VM loopback. `nginx_exporter` in the monitoring compose stack is configured to scrape `http://172.17.0.1:8081/nginx_status`.
`172.17.0.1` is the Docker bridge host IP — reachable from Docker containers but not from the public internet. `allow all` is safe because the listen address already limits access to bridge-network traffic. `nginx_exporter` is configured to scrape `http://172.17.0.1:8081/nginx_status`.

Verify from the VM:

Expand All @@ -209,45 +150,106 @@ Reading: 0 Writing: 1 Waiting: 2

## SSL

SSL certificates are managed by **Certbot** and must **not** be committed to this repository.
Certificates are managed by **Certbot** and must **not** be committed to this repository.

The configs reference certificates at:
Both configs reference:

```
/etc/letsencrypt/live/rythmify.duckdns.org/fullchain.pem
/etc/letsencrypt/live/rythmify.duckdns.org/privkey.pem
```

The certificate covers both `rythmify.duckdns.org` and `rythmify-back.duckdns.org` as Subject Alternative Names. Issue it with:
The certificate covers both domains as Subject Alternative Names:

```bash
sudo certbot certonly --nginx \
-d rythmify.duckdns.org \
-d rythmify-back.duckdns.org
```

Certbot installs a systemd timer (`certbot.timer`) for automatic renewal. Verify it is active:
Certbot installs a systemd timer for automatic renewal. Verify it is active:

```bash
sudo systemctl status certbot.timer
```

The files `options-ssl-nginx.conf` and `ssl-dhparams.pem` referenced in both configs are created automatically by Certbot when the first certificate is issued.
`options-ssl-nginx.conf` and `ssl-dhparams.pem` (referenced in both configs) are created automatically by Certbot on first issuance.

---

## Prerequisites

```bash
sudo apt update
sudo apt install -y nginx libnginx-mod-http-geoip2 geoipupdate certbot python3-certbot-nginx
```

---

## Deployment

**1. Copy configs to `sites-available`**

```bash
sudo cp nginx/rythmify-back.duckdns.org.conf /etc/nginx/sites-available/
sudo cp nginx/rythmify.duckdns.org.conf /etc/nginx/sites-available/
```

**2. Enable the sites**

```bash
sudo ln -sf /etc/nginx/sites-available/rythmify-back.duckdns.org.conf \
/etc/nginx/sites-enabled/
sudo ln -sf /etc/nginx/sites-available/rythmify.duckdns.org.conf \
/etc/nginx/sites-enabled/

# Disable the default site if still enabled
sudo rm -f /etc/nginx/sites-enabled/default
```

**3. Create the frontend static directory**

```bash
sudo mkdir -p /var/www/rythmify
sudo chown -R www-data:www-data /var/www/rythmify
```

**4. Test**

```bash
sudo nginx -t
```

Both lines must read `syntax is ok` and `test is successful`.

**5. Reload**

```bash
sudo systemctl reload nginx
```

**Applying updates from the repo:**

```bash
cd ~/devops && git pull
sudo cp nginx/rythmify-back.duckdns.org.conf /etc/nginx/sites-available/
sudo cp nginx/rythmify.duckdns.org.conf /etc/nginx/sites-available/
sudo nginx -t && sudo systemctl reload nginx
```

---

## Updating the GeoIP Database

MaxMind releases updated GeoLite2-Country databases approximately twice per month. To refresh the local copy manually:
MaxMind releases updated databases approximately twice per month:

```bash
sudo geoipupdate
sudo systemctl reload nginx
```

To automate this, create a cron job at `/etc/cron.d/geoipupdate`:
Automate with a cron job at `/etc/cron.d/geoipupdate`:

```cron
# Refresh MaxMind GeoLite2-Country on the 1st and 15th of each month at 03:00
0 3 1,15 * * root /usr/bin/geoipupdate && systemctl reload nginx
```
Loading
Loading