SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Cấu hình nginx reverse proxy từng dòng

Giải thích server block nginx trên Ubuntu 24.04: proxy_pass, 4 header cần có, WebSocket, slash cuối URL và giới hạn upload để tránh lỗi khi deploy.

Cấu hình nginx reverse proxy làm gì

nginx reverse proxy nhận các request đến cổng 80 và cổng 443, chuyển từng request đến một ứng dụng đang lắng nghe trên cổng local, rồi trả phản hồi của ứng dụng đó về browser. Cấu hình này chỉ là một block server và block này khá ngắn. Hầu hết phần khó nằm trong năm hoặc sáu dòng cho ứng dụng biết client thực sự là ai và client đã dùng protocol nào.

Toàn bộ nội dung dưới đây được xây dựng từ đầu trên Ubuntu 24.04, sử dụng package nginx do distribution cung cấp. Điểm bắt đầu là một ứng dụng đã phản hồi trên 127.0.0.1:3000. Nếu bạn chưa chọn proxy, hãy đọc trước phần so sánh nginx với Caddy và Traefik. Phần tiếp theo giải thích cấu hình nginx tương ứng, theo từng dòng.

Hãy chạy các cấu hình này trên server của bạn. Kiểm tra mọi thay đổi bằng sudo nginx -t trước khi reload và đọc nội dung lệnh in ra.

Vị trí nginx lưu cấu hình trên Ubuntu

sudo apt update
sudo apt install -y nginx
ls -l /etc/nginx/sites-enabled/

File chính là /etc/nginx/nginx.conf. File này thiết lập các tùy chọn toàn cục trong một block http { }, sau đó nạp hai thư mục: /etc/nginx/conf.d/*.conf/etc/nginx/sites-enabled/*. Trên Ubuntu và Debian, bạn tạo một file cho mỗi site trong /etc/nginx/sites-available/ rồi bật site bằng symlink đến /etc/nginx/sites-enabled/. Xóa symlink sẽ tắt site nhưng vẫn giữ lại file.

Hai directive được dùng sau này chỉ hoạt động trong context http, không hoạt động bên trong block server: mapupstream. Đặt chúng trong một file riêng bên dưới /etc/nginx/conf.d/, vì thư mục này được include ở cấp http.

Package cài sẵn một site đang được bật có tên default. Site này được đánh dấu default_server, nghĩa là nó sẽ xử lý mọi request có header Host không khớp với bất kỳ server_name nào trong cấu hình. Khi site này vẫn được bật, request không khớp với hostname của bạn sẽ đi vào site đó thay vì app của bạn. Xóa symlink sau khi site riêng của bạn hoạt động.

sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Khối server nhỏ nhất để proxy một ứng dụng

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Lưu nội dung đó vào /etc/nginx/sites-available/app.example.com, sau đó enable và load nó.

sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
curl -sI -H 'Host: app.example.com' http://127.0.0.1/

listen 80; bind IPv4 và listen [::]:80; bind IPv6. Nếu bỏ dòng thứ hai, người truy cập có DNS (domain name system) trả về bản ghi AAAA cho server của bạn sẽ nhận lỗi connection refused, trong khi mọi người dùng IPv4 vẫn thấy website hoạt động. Báo cáo lỗi bạn nhận được sẽ là “máy tôi vẫn truy cập được”.

server_name được đối chiếu với header Host mà trình duyệt gửi. Có thể liệt kê nhiều tên, cách nhau bằng khoảng trắng. Nếu không có block nào khớp, nginx dùng block được default_server, vì vậy phải xóa site được đóng gói sẵn.

location / là phép khớp prefix trên path của request, còn / khớp mọi path. proxy_pass là địa chỉ mà nginx mở kết nối đến. Giữ ứng dụng bind vào 127.0.0.1 để chỉ có thể truy cập thông qua nginx. Nếu ứng dụng chạy trong container, hãy publish ứng dụng bằng 127.0.0.1:3000:3000 thay vì 3000:3000, vì Docker tự ghi rule và publish port trực tiếp qua ufw, nên một port được publish đơn thuần vẫn có thể truy cập từ Internet bất kể firewall của bạn được cấu hình thế nào.

Dòng curl gửi đúng header Host từ chính server, để bạn có thể kiểm tra block trước khi DNS trỏ đến đâu đó.

Nginx gửi gì đến upstream khi bạn không cấu hình thêm

proxy_pass đứng riêng đã ẩn 4 thông tin đối với ứng dụng của bạn.

Theo mặc định, nginx dùng HTTP/1.0 với backend và gửi Connection: close. Vì vậy, mỗi request mở một kết nối upstream mới và không thể nâng cấp protocol.

Header Host được ghi đè thành giá trị trong proxy_pass, tức là 127.0.0.1:3000. Nếu ứng dụng tạo link tuyệt đối dựa trên Host, các link đó sẽ trỏ đến địa chỉ mà bên ngoài server không thể truy cập.

Kết nối đến ứng dụng xuất phát từ nginx, nên ứng dụng thấy địa chỉ client là 127.0.0.1. Khi đó, mọi dòng log và mọi rate limit trong ứng dụng đều ghi nhận proxy thay vì người truy cập.

Ứng dụng không thể biết browser đã dùng HTTPS, vì kết nối mà nó nhận được là HTTP thuần trên một địa chỉ loopback.

4 dòng sẽ khắc phục toàn bộ vấn đề đó.

Bốn header cần đặt và thông tin mà mỗi header cho backend biết

location / {
    proxy_pass http://127.0.0.1:3000;

    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;
}

Host chứa tên mà người truy cập đã nhập. $host là tên lấy từ request, sau khi bỏ port và chuyển toàn bộ chữ cái thành chữ thường. Hãy đặt header này để app tạo đúng các URL tuyệt đối, chẳng hạn redirect sau khi đăng nhập hoặc liên kết trong email đặt lại mật khẩu. Nếu bỏ header này, các URL đó sẽ trỏ đến 127.0.0.1:3000, khiến trình duyệt được chuyển đến một địa chỉ từ chối kết nối. Nếu app cũng cần port vì đang phục vụ trên 8080, hãy dùng $http_host. Đây là header đúng như client đã gửi.

X-Real-IP chứa một giá trị: $remote_addr, địa chỉ mà nginx đã nhận kết nối từ đó. App đọc giá trị này để ghi access log riêng và áp dụng rate limiting riêng.

X-Forwarded-For chứa một danh sách. $proxy_add_x_forwarded_for nối thêm $remote_addr vào giá trị mà client đã đặt trong header đó, nên giá trị được phân tách bằng dấu phẩy và entry do nginx thêm vào nằm cuối cùng. Chi tiết này quyết định header có đáng tin hay không: client có thể gửi bất kỳ X-Forwarded-For nào, nên app đọc entry đầu tiên có thể bị cung cấp một địa chỉ tùy ý. Khi nginx là edge server, hãy ghi đè bằng $remote_addr và loại bỏ giá trị do client gửi. Khi có CDN hoặc proxy khác đứng trước, hãy dùng set_real_ip_fromreal_ip_header từ module realip để $remote_addr trở thành địa chỉ client thực.

X-Forwarded-Proto chứa http hoặc https. Framework đọc giá trị này để quyết định có đánh dấu cookie là Secure hay không và có buộc redirect sang HTTPS hay không. Nếu bỏ header này trên site dùng TLS, app được cấu hình để buộc HTTPS sẽ thấy http, trả về redirect đến địa chỉ HTTPS, nhận request tiếp theo thông qua nginx, vẫn thấy http và redirect lần nữa. Trình duyệt sẽ dừng lại và hiển thị ERR_TOO_MANY_REDIRECTS.

Lặp lại bốn dòng này trong mọi location sẽ khiến cấu hình dần không nhất quán. Hãy đặt chúng trong một file rồi include file đó.

# /etc/nginx/snippets/proxy-headers.conf
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;
location / {
    include snippets/proxy-headers.conf;
    proxy_pass http://127.0.0.1:3000;
}

Cơ chế inheritance ở đây có một bẫy. Một location chỉ kế thừa các directive proxy_set_header từ server block khi location đó không tự định nghĩa directive nào cùng loại. Chỉ cần thêm một proxy_set_header trong location, toàn bộ header được định nghĩa ở cấp server sẽ bị loại bỏ khỏi location đó. Vì vậy, hãy giữ tất cả header ở cùng một cấp hoặc include snippet trong từng location có proxy.

Tại sao ứng dụng WebSocket kết nối được rồi lại ngắt?

Vì các giá trị mặc định không cho phép upgrade, còn read timeout mặc định sẽ đóng tunnel không có lưu lượng sau 60 giây. WebSocket bắt đầu bằng một HTTP request chứa Upgrade: websocketConnection: Upgrade. Đây là các hop-by-hop header, nghĩa là proxy phải xử lý chúng thay vì chuyển tiếp, và HTTP/1.0 hoàn toàn không có cơ chế upgrade. Cả hai header đều phải được thêm lại thủ công.

Đặt map trong context http, trong một file riêng.

# /etc/nginx/conf.d/websocket.conf
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Tiếp theo là location.

location / {
    include snippets/proxy-headers.conf;
    proxy_pass http://127.0.0.1:3000;

    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

Map tồn tại để một location có thể phục vụ cả hai loại traffic. Với request thông thường, $http_upgrade rỗng nên $connection_upgrade trở thành close. Với request upgrade, nó chứa websocket nên header gửi lên upstream là Connection: upgrade. Nếu hard-code proxy_set_header Connection "upgrade";, header đó sẽ được gửi cùng mọi request đến trang thông thường, và một số backend sẽ trả về 400 cho request như vậy.

proxy_read_timeout là nguyên nhân tạo ra các báo cáo kiểu “trang tải được rồi ngừng cập nhật”. Giá trị mặc định là 60 giây. Nó đo khoảng thời gian giữa hai lần đọc từ backend, không phải thời gian tồn tại của connection. WebSocket không có dữ liệu trong 60 giây sẽ bị nginx đóng, còn console của trình duyệt hiển thị socket đóng với code 1006. Các app tự gửi heartbeat thường xuyên hơn 1 lần mỗi phút sẽ không gặp vấn đề này. Các app không làm vậy sẽ chết đúng sau 1 phút. Live editor và dashboard thường là nơi phát hiện lỗi đầu tiên, một instance n8n tự host phía sau HTTPS là ví dụ phổ biến.

Vì sao dấu gạch chéo ở cuối trong proxy_pass làm thay đổi URL?

Quy tắc này rất đơn giản. Nếu proxy_pass kết thúc bằng một URI (uniform resource identifier), kể cả chỉ là /, nginx sẽ xóa phần request path khớp với prefix location rồi thay bằng URI đó. Nếu proxy_pass chỉ dừng ở hostname và port, request path được chuyển tiếp nguyên vẹn.

location /app/ {
    proxy_pass http://127.0.0.1:3000/;
}

Request đến /app/status sẽ đến backend dưới dạng /status.

location /app/ {
    proxy_pass http://127.0.0.1:3000;
}

Request đến /app/status sẽ đến backend dưới dạng /app/status.

Bạn chọn dạng nào tùy thuộc vào app. App có cấu hình base-path hoặc sub-folder sẽ dùng dạng thứ hai và phải cấu hình để nhận biết /app. App không biết gì về prefix cần dạng thứ nhất. Dạng thứ nhất có một vấn đề thấy ngay: HTML do app trả về vẫn chứa các path tuyệt đối như /static/main.css, browser sẽ yêu cầu chúng từ site root, không có location nào khớp, và trang được render mà không có CSS. Network tab của browser sẽ hiển thị các request asset đó trả về 404. Cách sửa là cấu hình base-path của app hoặc thêm một location /static/ trỏ đến cùng backend.

Một regex location không được chứa URI trong proxy_pass. sudo nginx -t sẽ từ chối config và nêu rõ lý do: "proxy_pass" cannot have URI part in location given by regular expression, or inside named location, or inside "if" statement, or inside "limit_except" block.

Toàn bộ nhóm vấn đề này biến mất khi mỗi app có hostname riêng, app.example.com, và được proxy từ location /. Chỉ nên dùng sub-path khi bạn không thể thêm DNS record.

Làm thế nào để đặt nhiều backend phía sau một hostname?

Dùng một block upstream. Block này thuộc context http, nên hãy đặt nó phía trên block server trong cùng file hoặc trong /etc/nginx/conf.d/.

upstream app_backend {
    least_conn;
    server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

Sau đó, directive location tham chiếu đến nó: proxy_pass http://app_backend;.

Phương thức mặc định là round robin. least_conn gửi mỗi request đến backend đang có ít kết nối active nhất, phù hợp với các request có thời lượng không đồng đều. ip_hash ghim một địa chỉ client vào một backend. Bạn cần ip_hash khi app lưu session trong bộ nhớ riêng, vì round robin trên 2 backend kiểu này sẽ làm người dùng bị đăng xuất ngẫu nhiên khi request của họ đến instance chưa từng nhận request trước đó. Chuyển session vào storage dùng chung là cách tốt hơn.

max_fails=3 fail_timeout=30s có nghĩa là 3 lần thử thất bại trong 30 giây sẽ đưa server đó ra khỏi pool trong 30 giây. Khi mọi server trong block đều ở trạng thái này, client nhận 502 và error log ghi no live upstreams while connecting to upstream.

keepalive 32 giữ tối đa 32 connection idle đến backend mở sẵn cho mỗi worker process, nhờ đó loại bỏ bước TCP handshake khỏi phần lớn request. Nó chỉ hoạt động với proxy_http_version 1.1 và khi không có Connection: close nào đi upstream. Nếu cùng location cũng dùng WebSocket map, hãy đổi giá trị rỗng từ close thành chuỗi rỗng, để request thông thường không gửi header Connection và connection trong pool được tái sử dụng.

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

Các hostname trong block upstream được phân giải khi nginx khởi động. Nếu backend của bạn là một container nhận địa chỉ mới sau mỗi lần restart, nginx vẫn tiếp tục dùng địa chỉ cũ cho đến khi bạn reload nó. Trong Docker network, bạn có thể chuyển việc lookup sang thời điểm xử lý request bằng resolver tích hợp.

resolver 127.0.0.11 valid=10s;
set $backend http://app:3000;
proxy_pass $backend;

Khi container xuất hiện và biến mất thường xuyên đến mức bạn phải liên tục sửa cấu hình nginx để theo kịp, proxy đọc label của container sẽ phù hợp hơn. Đặt Traefik phía trước nhiều app Docker Compose xây dựng route trực tiếp từ các container.

Vì sao upload thất bại với lỗi 413 Request Entity Too Large?

client_max_body_size mặc định là 1 megabyte. nginx từ chối request body lớn hơn trước khi app nhận được dữ liệu, và error log ghi lại client intended to send too large body. Tăng giá trị này trong server block hoặc trong location xử lý upload.

client_max_body_size 512m;

Giá trị 0 sẽ tắt hoàn toàn việc kiểm tra. App cũng có giới hạn riêng, nên nếu lỗi 413 vẫn xuất hiện sau thay đổi này thì lỗi đến từ backend. Khi đó, hãy kiểm tra setting upload của app.

Theo mặc định, nginx đọc toàn bộ request body trước khi mở kết nối upstream. Dữ liệu lớn được ghi vào temporary file trên disk trước. Cách này bảo vệ app khỏi client chậm, vì backend nhận upload với tốc độ nội bộ tối đa. Với upload rất lớn, bạn có thể stream dữ liệu.

proxy_request_buffering off;

Khi đó, backend nhận request body ngay khi dữ liệu đến và phải có khả năng xử lý theo cách này. nginx cũng không thể retry request với upstream khác, vì request body đã được chuyển đi.

client_body_timeout, mặc định là 60 giây, áp dụng giữa hai lần đọc body liên tiếp thay vì áp dụng cho toàn bộ upload. Upload chậm nhưng liên tục vẫn được chấp nhận. Upload bị ngừng sẽ bị ngắt.

Bộ đệm response và thiết lập làm hỏng output trực tiếp

proxy_buffering được bật mặc định và thường là thiết lập bạn cần. nginx đọc response từ app nhanh nhất theo tốc độ app có thể ghi, giữ response trong bộ đệm, rồi gửi cho client chậm theo tốc độ riêng của client đó. Worker của app hoàn tất sớm thay vì phải bận trong suốt thời gian client tải chậm.

Thiết lập này làm hỏng các response dạng streaming. Server-sent events và output log trực tiếp không hiển thị gì với người đọc cho đến khi bộ đệm đầy. Chỉ tắt buffering tại location đó.

proxy_buffering off;

Nếu bạn kiểm soát app, cách tốt hơn là chỉ gửi header X-Accel-Buffering: no trên các response streaming. nginx đọc header đó theo từng response và chỉ tắt buffering cho response đó, nên các trang thông thường vẫn giữ được lợi ích của buffering.

Khi error log ghi upstream sent too big header while reading response header from upstream, các response header không vừa trong một buffer. proxy_buffer_size mặc định bằng một memory page, 4 hoặc 8 kilobytes tùy nền tảng, và cookie dài hoặc authentication header lớn có thể làm buffer bị tràn. Tăng cả hai giá trị.

proxy_buffer_size 16k;
proxy_buffers 8 16k;

TLS nên đặt ở đâu trong cấu hình này?

Đặt tại nginx, phía trước toàn bộ các thành phần ở trên. TLS (bảo mật tầng truyền tải) được termination tại proxy. Kết nối từ nginx đến app vẫn là HTTP thuần qua địa chỉ loopback, nên không có thành phần nào khác trên network đọc được kết nối này. App biết visitor đã dùng HTTPS thông qua X-Forwarded-Proto, là header thứ tư trong 4 header.

Không tự viết đường dẫn đến certificate. Trỏ bản ghi DNS đến server, mở firewall, rồi để Certbot chỉnh sửa server block này: Certbot thêm dòng listen 443 ssl với các đường dẫn ssl_certificate, đồng thời redirect từ port 80. Cấp certificate Let's Encrypt cho nginx bằng Certbot trình bày cách cấp certificate và timer gia hạn.

sudo ufw allow 'Nginx Full'
sudo ufw status

Nginx Full là application profile do package nginx cài đặt. Profile này mở đồng thời port 80 và port 443. Port 80 phải tiếp tục mở để phục vụ HTTP-01 renewal challenge, kể cả khi mọi visitor đều được redirect sang HTTPS.

Kiểm tra cấu hình, sau đó reload

sudo nginx -t
sudo systemctl reload nginx

nginx -t phân tích mọi file được include rồi báo kiểm tra thành công hoặc in ra file và dòng mà tại đó quá trình dừng lại. Hãy đọc output đó trước khi reload. Reload với cấu hình lỗi sẽ không được áp dụng: nginx tiếp tục phục vụ bằng cấu hình trước đó, nên site vẫn hoạt động nhưng thay đổi của bạn không có tác dụng và không được báo lỗi. systemctl restart hoạt động khác và gây hậu quả nghiêm trọng hơn, vì restart sẽ dừng server đang chạy trước. Khi cấu hình có lỗi, nginx sẽ không còn chạy. Mặc định hãy dùng reload. Chỉ dùng restart cho những thay đổi hiếm gặp thực sự yêu cầu thao tác này.

sudo tail -f /var/log/nginx/error.log
sudo ss -lntp | grep -E ':(80|443|3000)'

Dòng ss cho biết process nào đang giữ từng port. Nhờ đó, bạn có thể xác nhận app thực sự đang listen tại vị trí mà proxy_pass chỉ đến.

Các lỗi bạn thực sự sẽ gặp

502 Bad Gateway, kèm connect() failed (111: Connection refused) while connecting to upstream trong error log. Không có tiến trình nào đang listen tại địa chỉ trong proxy_pass. App đã dừng, đang bind vào cổng khác hoặc bind vào địa chỉ nội bộ của container mà host không thể truy cập.

502 kèm no live upstreams while connecting to upstream. Tất cả server trong block upstream hiện được max_fails đánh dấu là failed. Hãy khắc phục các backend. nginx sẽ thử lại chúng sau khi fail_timeout hết hạn.

504 Gateway Time-out, kèm upstream timed out (110: Connection timed out) while reading response header from upstream. Backend đã chấp nhận kết nối nhưng không gửi dữ liệu nào trong proxy_read_timeout giây. Tăng timeout là đúng với báo cáo thực sự chạy chậm, nhưng không giải quyết được app bị treo.

Mọi path đều trả về 404 từ app. Rule thêm dấu slash ở cuối đã rewrite path. So sánh path được ghi trong log của app với path bạn đã request.

Một site khác trả lời. server_name không khớp với header Host, nên request đã rơi xuống block default_server.

Trang tải xong, rồi interface bị treo sau khoảng một phút. Đây là trường hợp WebSocket: thiếu cấu hình xử lý Upgrade hoặc proxy_read_timeout vẫn là 60 giây.

FAQ

Vì sao nginx trả về 502 Bad Gateway sau khi tôi thêm proxy_pass?

nginx không thể mở kết nối đến địa chỉ trong proxy_pass. Error log tại /var/log/nginx/error.log nêu nguyên nhân: connect() failed (111: Connection refused) while connecting to upstream nghĩa là không có tiến trình nào đang listen tại đó, còn no live upstreams nghĩa là mọi server trong block upstream đã bị đánh dấu là failed. Chạy sudo ss -lntp | grep 3000 để xem process nào đang giữ port và đang bind vào địa chỉ nào. App bind vào địa chỉ nội bộ của container hoặc vào port khác với port bạn đã khai báo sẽ luôn gây ra lỗi này.

Vì sao app của tôi bị ngắt kết nối sau khoảng một phút khi chạy sau nginx?

Kết nối này là WebSocket và proxy_read_timeout vẫn đang dùng giá trị mặc định là 60 giây. Giá trị này đo khoảng thời gian giữa hai lần đọc dữ liệu từ backend. nginx sẽ đóng socket không có dữ liệu, còn browser console sẽ báo close code 1006. Đặt proxy_http_version 1.1, truyền tiếp UpgradeConnection bằng map trên $http_upgrade, đồng thời tăng proxy_read_timeout lên khoảng 3600s. Nếu thiếu header Upgrade thì quá trình upgrade không xảy ra, khiến app chuyển sang polling hoặc không hiển thị cập nhật theo thời gian thực.

Dấu gạch chéo ở cuối proxy_pass có quan trọng không?

Có. Nó thay đổi path mà backend nhận được. Với location /app/proxy_pass http://127.0.0.1:3000/, request đến /app/status sẽ đến backend dưới dạng /status, vì mọi URI sau host và port sẽ thay thế prefix của location đã match. Bỏ dấu gạch chéo cuối cùng thì request tương tự sẽ đến dưới dạng /app/status. Việc loại bỏ prefix thường làm hỏng các asset link của app. Những link này vẫn là absolute và sau đó trả về 404 tại site root. Vì vậy, với app có cấu hình base-path, nên dùng dạng truyền nguyên path.

Vì sao application của tôi ghi nhận 127.0.0.1 là IP của mọi visitor?

Vì kết nối mà app nhận được thực sự đến từ nginx trên địa chỉ loopback. Địa chỉ của visitor chỉ được chuyển đến app qua header bạn thiết lập: proxy_set_header X-Real-IP $remote_addr; cho một giá trị đơn và proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; cho chuỗi được nối thêm. Sau đó, app phải được cấu hình để trust các header này. Lưu ý rằng client có thể tự gửi X-Forwarded-For. Vì vậy, khi nginx là edge server, hãy ghi đè giá trị này bằng $remote_addr thay vì nối thêm.

Tôi có cần TLS trên kết nối giữa nginx và app không?

Không cần nếu app chạy trên cùng server và bind vào 127.0.0.1, vì traffic đó không rời khỏi máy. Terminate TLS tại nginx, giữ proxy_pass ở dạng HTTP plain trên loopback và gửi X-Forwarded-Proto $scheme để app biết visitor đã dùng HTTPS. Nếu backend nằm trên host khác qua một network mà bạn không kiểm soát, hop đó cần có cơ chế bảo vệ riêng: dùng HTTPS đến backend hoặc thiết lập private tunnel giữa hai máy.