# Ẩn IP origin server cho project Spa

> Cập nhật: 30/09/2026  
> Phạm vi: project Laravel 5.8 chạy bằng Docker Compose, Nginx và PHP-FPM.

## Kết luận nhanh

Phương án nên dùng cho project này là **Cloudflare Tunnel**. `cloudflared` tạo kết nối đi ra từ server tới Cloudflare nên server không cần mở public port `80/443`. Sau khi bỏ publish port của Nginx, MySQL và Redis, người ngoài không thể đi thẳng tới các container bằng IP origin.

Chỉ bật biểu tượng đám mây màu cam cho bản ghi DNS **không đủ**: IP cũ có thể còn trong lịch sử DNS, record mail/DNS-only có thể làm lộ IP, và người biết IP vẫn có thể gọi thẳng origin nếu firewall cho phép. Cloudflare cũng khuyến nghị Tunnel hoặc chặn mọi kết nối không đến từ Cloudflare để bảo vệ origin.

Cloudflare Tunnel khả dụng cho mọi gói, gồm gói Free. Với một website public thông thường, phần Tunnel không cần mua Argo Smart Routing hay Spectrum. Bạn vẫn phải trả chi phí tên miền/VPS hiện có; một số tính năng WAF, Access hoặc log nâng cao có giới hạn riêng theo gói và không phải điều kiện bắt buộc của hướng dẫn này.

Kiến trúc đề xuất:

```text
Người dùng
    |
    v
Cloudflare (DNS, TLS, chống DDoS/WAF)
    |
    | tunnel do cloudflared chủ động kết nối ra ngoài
    v
cloudflared container ---> nginx:80 ---> app:9000
                                      |-> mysql:3306 (mạng Docker nội bộ)
                                      `-> redis:6379 (mạng Docker nội bộ)
```

## Những điểm đang cần chú ý trong project

File `docker-compose.yml` hiện tại publish:

- Nginx: `${APP_PORT:-80}:80`
- MySQL: `${MYSQL_FORWARD_PORT:-3307}:3306`
- Redis: `${REDIS_FORWARD_PORT:-6379}:6379`

Nếu server có IP public và firewall/security group cho phép, ba cổng này có thể bị truy cập từ Internet. MySQL và Redis không cần publish port trong môi trường production vì container `app` đã truy cập chúng qua mạng nội bộ Docker bằng tên service `mysql` và `redis`.

## Phương án A — Cloudflare Tunnel (khuyên dùng)

### 1. Điều kiện ban đầu

- Có tên miền và đã chuyển nameserver của tên miền sang Cloudflare.
- Có quyền quản trị server và Docker Compose.
- Server được phép tạo kết nối outbound tới Cloudflare. Nếu outbound firewall bị giới hạn, kiểm tra ít nhất port `7844`; xem tài liệu [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/).
- Đảm bảo vẫn có một phiên SSH đang hoạt động trước khi thay đổi firewall. Không đóng phiên đó cho tới khi đã thử đăng nhập bằng một terminal khác.

### 2. Tạo Tunnel trên Cloudflare

1. Đăng nhập Cloudflare Dashboard.
2. Vào **Networking → Tunnels**.
3. Chọn **Create a tunnel**, đặt tên ví dụ `spa-production`.
4. Chọn môi trường **Docker**.
5. Sao chép riêng chuỗi token bắt đầu bằng `eyJ...`; không commit hoặc gửi token này qua chat/email.
6. Trong Tunnel, chọn **Add route → Published application**:
   - Hostname: ví dụ `app.example.com`.
   - Service type: `HTTP`.
   - Service URL: `nginx:80`.

`nginx` ở đây là tên service trong `docker-compose.yml`, không phải `localhost`. Cloudflare sẽ tự tạo/quản lý record CNAME trỏ tới Tunnel khi thêm published application. Hướng dẫn chính thức: [Create a tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/).

### 3. Lưu token an toàn

Thêm vào file `.env` trên server, tuyệt đối không thêm token thật vào `.env.example`:

```dotenv
CLOUDFLARE_TUNNEL_TOKEN=eyJ...token-thuc-te...
```

Project đã ignore `.env` và `.env.*` trong `.gitignore`. Vẫn nên kiểm tra trước khi commit:

```bash
git check-ignore .env
git status --short
```

Ai có Tunnel token đều có thể chạy connector cho tunnel đó. Nếu token bị lộ, vào trang Tunnel chọn **Refresh token**, cập nhật `.env`, rồi tạo lại container. Xem [Tunnel token security](https://developers.cloudflare.com/tunnel/reference/tunnel-tokens/).

### 4. Điều chỉnh `docker-compose.yml`

Sửa phần liên quan theo mẫu dưới đây. Các phần `build`, `volumes`, `environment` hiện có khác vẫn giữ nguyên.

```yaml
services:
  app:
    # Giữ cấu hình hiện có.

  nginx:
    # Giữ build, volumes và depends_on hiện có.
    # XÓA mục ports trên production; Nginx chỉ còn ở mạng Docker nội bộ.
    expose:
      - "80"

  mysql:
    # Giữ cấu hình hiện có.
    # XÓA mục ports; app kết nối bằng DB_HOST=mysql, DB_PORT=3306.
    expose:
      - "3306"

  redis:
    # Giữ cấu hình hiện có.
    # XÓA mục ports; app kết nối bằng REDIS_HOST=redis, REDIS_PORT=6379.
    expose:
      - "6379"

  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: spa_cloudflared
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
    depends_on:
      - nginx
```

Ghi chú:

- `expose` chỉ mang tính mô tả/khai báo cho mạng container; nó không publish cổng ra host. Có thể bỏ hẳn `expose` mà các service cùng mạng mặc định vẫn liên lạc được.
- Biến môi trường `TUNNEL_TOKEN` được `cloudflared` hỗ trợ chính thức. Cách này tránh đặt token trực tiếp trong command/process listing; xem [cloudflared run parameters](https://developers.cloudflare.com/tunnel/reference/run-parameters/).
- Trong production ổn định, nên pin image vào một version hoặc digest đã kiểm thử thay vì để `latest`, rồi chủ động cập nhật định kỳ.
- Nếu quản trị MySQL từ máy cá nhân, không mở lại port `3306/3307` ra toàn Internet. Dùng SSH tunnel, VPN hoặc bind tạm vào `127.0.0.1`, ví dụ `127.0.0.1:3307:3306`.

### 5. Sửa biến môi trường production của Laravel

Các giá trị tối thiểu nên là:

```dotenv
APP_ENV=production
APP_DEBUG=false
APP_URL=https://app.example.com

DB_HOST=mysql
DB_PORT=3306
REDIS_HOST=redis
REDIS_PORT=6379
```

Không dùng `127.0.0.1` cho MySQL/Redis khi Laravel chạy trong container `app`, vì `127.0.0.1` lúc này chính là container `app`, không phải container database/cache.

Sau khi cập nhật `.env`:

```bash
docker compose config --quiet
docker compose up -d --build
docker compose exec app php artisan config:clear
docker compose ps
docker compose logs --tail=100 cloudflared nginx app
```

Không dán output của `docker compose config` vào nơi công khai vì output có thể chứa secret đã được nội suy.

### 6. HTTPS và IP thật của người dùng

TLS từ trình duyệt tới Cloudflare được Cloudflare xử lý. Đoạn Tunnel nội bộ có thể dùng `http://nginx:80` vì nó nằm trong mạng Docker và đi vào Cloudflare qua tunnel mã hóa; không cần tạo certificate public trong Nginx cho mô hình này.

Project đã có middleware `App\Http\Middleware\TrustProxies`. Để Laravel nhận đúng scheme `https` qua reverse proxy, kiểm tra kỹ trên môi trường staging rồi có thể đặt:

```php
// app/Http/Middleware/TrustProxies.php
protected $proxies = '*';
```

Chỉ nên trust mọi proxy theo cách trên khi Nginx **không còn public port** và chỉ nhận request từ mạng Docker/Tunnel. Nếu origin vẫn truy cập công khai, phải giới hạn proxy được tin cậy thay vì dùng `*`.

Cloudflare gửi IP người dùng trong header `CF-Connecting-IP`. Nginx mặc định sẽ nhìn thấy IP của container `cloudflared`; ứng dụng nào dùng IP để rate-limit, audit hoặc thanh toán cần được kiểm thử. Riêng project này có đoạn dùng trực tiếp `$_SERVER['REMOTE_ADDR']` trong `OrdersController`, nên giá trị đó có thể không còn là IP khách sau khi đặt sau proxy. Không được tin `CF-Connecting-IP` từ một origin đang mở công khai vì client có thể tự giả header. Tham khảo [Restoring original visitor IPs](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/).

### 7. Khóa firewall ở host/cloud provider

Sau khi Tunnel báo **Healthy** và domain hoạt động:

- Đóng inbound `80`, `443`, `3306/3307`, `6379` ở cả firewall của nhà cung cấp (Security Group/Cloud Firewall) và host firewall.
- Chỉ giữ SSH từ IP quản trị cố định hoặc chuyển SSH qua VPN/Cloudflare Access.
- Không xóa rule SSH trước khi xác nhận một phiên đăng nhập mới hoạt động.
- Không chỉ dựa vào UFW để che cổng do Docker publish; cách chắc chắn nhất là xóa `ports` khỏi Compose như trên và kiểm tra lại cổng đang listen.

Ví dụ kiểm tra trên server:

```bash
docker compose ps
ss -lntup
```

Kết quả mong đợi: không có `0.0.0.0:80`, `0.0.0.0:443`, `0.0.0.0:3307` hoặc `0.0.0.0:6379` phục vụ project này.

### 8. Kiểm tra sau triển khai

Từ **một máy khác ngoài server**:

```bash
dig +short app.example.com A
dig +short app.example.com AAAA
curl -I https://app.example.com
curl -I http://IP_ORIGIN
curl -I -H 'Host: app.example.com' http://IP_ORIGIN
```

Kỳ vọng:

- `dig` trả về IP Cloudflare, không trả IP origin.
- Domain HTTPS trả response của ứng dụng.
- Hai request trực tiếp tới `IP_ORIGIN` timeout hoặc bị từ chối, kể cả khi giả `Host` header.

Chỉ quét IP do chính bạn sở hữu/quản lý. Có thể kiểm tra thêm:

```bash
nmap -Pn -p 80,443,3306,3307,6379 IP_ORIGIN
```

Các cổng web/database/cache nên là `closed` hoặc `filtered`. SSH chỉ nên mở theo chính sách quản trị đã chọn.

### 9. Rà soát các đường làm lộ IP

- Xóa mọi record `A`/`AAAA` cũ trỏ trực tiếp tới origin nếu không còn dùng.
- Kiểm tra tất cả record **DNS only**. Một subdomain như `direct`, `origin`, `dev`, `api`, `ftp` trỏ cùng IP sẽ làm lộ origin.
- Không đặt mail server trên cùng IP với web origin nếu muốn che IP. Record `MX`, SPF và email header có thể tiết lộ hạ tầng mail.
- Kiểm tra Git history, tài liệu, file backup, CI logs và ảnh chụp màn hình có chứa IP/token.
- Nếu IP đã từng công khai, nên đổi/rotate IP origin sau khi Tunnel ổn định; lịch sử DNS có thể vẫn lưu IP cũ.
- Không nhúng IP origin vào JavaScript, webhook URL, mobile app hoặc cấu hình API client.
- Tunnel che đường **inbound** tới web origin; nó không ẩn IP **outbound** khi server gọi API bên thứ ba, gửi email hoặc kết nối dịch vụ khác.

Cloudflare có cảnh báo cho record có nguy cơ làm lộ origin; xem [Exposed IP addresses](https://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/exposed-ip-address/).

## Phương án B — Proxy DNS + firewall chỉ cho IP Cloudflare

Dùng khi không thể chạy `cloudflared`.

1. Tạo record `A`/`AAAA` tới origin và bật **Proxied** (đám mây cam).
2. Origin phải phục vụ HTTPS với certificate hợp lệ (Let's Encrypt hoặc Cloudflare Origin CA).
3. Đặt SSL/TLS mode thành **Full (strict)**, không dùng Flexible.
4. Ở firewall/security group, chỉ allow port `80/443` từ [dải IP Cloudflare chính thức](https://www.cloudflare.com/ips/), rồi block tất cả nguồn khác.
5. Tự động cập nhật allowlist khi Cloudflare thay đổi dải IP.
6. Có thể bật **Authenticated Origin Pulls** để origin xác minh request đến từ Cloudflare. Tính năng này có trên các plan, nhưng không áp dụng cho hostname dùng Tunnel; xem [Authenticated Origin Pulls](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/).
7. Vẫn phải xóa publish port MySQL và Redis.

Phương án này kém gọn hơn Tunnel: origin vẫn có public listener, phải duy trì certificate và allowlist IP, và IP có thể đã tồn tại trong lịch sử DNS. Cloudflare khuyến nghị block toàn bộ nguồn ngoài dải Cloudflare nếu dùng mô hình proxy truyền thống: [Protect your origin server](https://developers.cloudflare.com/fundamentals/security/protect-your-origin-server/).

## Các lựa chọn tương tự

| Giải pháp | Phù hợp | Hạn chế chính |
|---|---|---|
| Cloudflare Tunnel | Production, domain riêng, muốn đóng toàn bộ inbound web | Phụ thuộc Cloudflare; phải bảo vệ Tunnel token |
| Cloudflare proxy + firewall | Không thể cài connector trên origin | Phải mở web port cho Cloudflare, duy trì allowlist/certificate |
| Tailscale Funnel | Demo hoặc dịch vụ nhỏ đã dùng Tailscale | Cần kiểm tra giới hạn hostname, port và điều khoản hiện hành trước khi dùng production |
| ngrok | Demo, webhook, test tạm thời | Free tier thường có giới hạn hostname/băng thông/phiên; không nên coi là kiến trúc production mặc định |
| VPS reverse proxy riêng | Muốn tự kiểm soát proxy/IP public | Tốn thêm VPS, vận hành TLS/firewall/DDoS; phải khóa origin chỉ nhận từ proxy |

Không dùng “đổi DNS sang CDN” như biện pháp duy nhất. Tiêu chí quyết định là: **origin có từ chối được mọi kết nối trực tiếp hay không**.

## Checklist hoàn tất

- [ ] Domain dùng nameserver Cloudflare.
- [ ] Tunnel ở trạng thái `Healthy`.
- [ ] Published application trỏ tới `http://nginx:80`.
- [ ] `CLOUDFLARE_TUNNEL_TOKEN` chỉ nằm trong secret hoặc `.env` không commit.
- [ ] Nginx không publish port ra host.
- [ ] MySQL và Redis không publish port ra host.
- [ ] `APP_URL` dùng `https://` và `APP_DEBUG=false`.
- [ ] Domain trả IP Cloudflare khi chạy `dig`.
- [ ] Truy cập trực tiếp IP origin bị timeout/refused.
- [ ] Không có record DNS-only/mail/subdomain khác làm lộ cùng IP.
- [ ] Đã kiểm thử login, upload, webhook, queue, URL sinh bởi Laravel và các luồng cần IP khách.
- [ ] Đã rotate IP origin nếu IP cũ từng công khai và nhà cung cấp hỗ trợ.
- [ ] Có phương án SSH/quản trị không phụ thuộc public web port.

## Rollback an toàn

Nếu Tunnel lỗi:

1. Giữ nguyên container/app và kiểm tra `docker compose logs cloudflared`.
2. Sửa lại Service URL thành `http://nginx:80` nếu cấu hình sai.
3. Nếu bắt buộc khôi phục public web tạm thời, chỉ bind Nginx vào host sau khi đã chuẩn bị firewall allowlist; không mở MySQL/Redis.
4. Không vô hiệu toàn bộ firewall và không công khai Tunnel token để xử lý sự cố.

Ví dụ fallback cục bộ để kiểm tra ngay trên server, không public Internet:

```yaml
nginx:
  ports:
    - "127.0.0.1:8080:80"
```

Sau đó test trên server:

```bash
curl -I -H 'Host: app.example.com' http://127.0.0.1:8080
```

Khi sửa xong, xóa binding tạm và chạy lại `docker compose up -d`.
