Konfiguracja nginx jako reverse proxy
Od proxy_pass po upgrade WebSocketa: kompletny przewodnik po nginx jako reverse proxy, razem z czterema nagłówkami, bez których twoja aplikacja uzna każdego odwiedzającego za 127.0.0.1.
Prawie każda nowoczesna aplikacja nasłuchuje gdzieś na wysokim porcie: Node na 3000, kontener Dockera na 8080, Gunicorn na 8000, serwer aplikacji Javy na 8443. Czegoś takiego nie wystawia się wprost do internetu. Przed aplikacją powinien stać reverse proxy, a w praktyce jest nim nginx.
Podstawowa konfiguracja mieści się w pięciu wierszach. I właśnie w tym tkwi problem: te pięć wierszy pozornie działa, a trzy tygodnie później okazuje się, że każdy odwiedzający figuruje w logu aplikacji jako 127.0.0.1, że limit żądań przy logowaniu blokuje wszystkich naraz, a wiadomość z resetem hasła zawiera odnośnik do http://127.0.0.1:3000. Ten artykuł zajmuje się dokładnie tymi miejscami.
Warunkiem jest zainstalowany nginx. Jeśli jeszcze go nie masz, pomoże instalacja nginx na Debianie i Ubuntu.
Co reverse proxy naprawdę robi od strony technicznej
nginx przyjmuje połączenie odwiedzającego, a następnie otwiera własne, drugie połączenie do aplikacji. To jest kluczowy punkt, z którego wynikają wszystkie dalsze problemy.
Z punktu widzenia aplikacji klientem nie jest odwiedzający, tylko nginx. Adresem nadawcy jest 127.0.0.1. Protokołem jest http, nawet jeśli na zewnątrz działał HTTPS. Nagłówek Host domyślnie brzmi 127.0.0.1:3000, a nie app.example.com. Do tego HTTP/1.0 zamiast HTTP/1.1, przez co WebSockety bez dodatkowej konfiguracji zawodzą zawsze.
Wszystko, co aplikacja ma wiedzieć o prawdziwym odwiedzającym, nginx musi jej aktywnie przekazać w nagłówkach HTTP. Samo z siebie nic się nie wydarzy.
Podstawowa konfiguracja i jej właściwe miejsce
Miejsce przechowywania pliku różni się w zależności od systemu, co bywa regularnie mylone.
Debian i Ubuntu: konfiguracja leży w /etc/nginx/sites-available/app.conf i jest aktywowana symlinkiem w /etc/nginx/sites-enabled/. W przeciwnym razie wszystkie żądania przechwyci domyślny blok server o nazwie default.
AlmaLinux, Rocky, RHEL i Oracle Linux: tam sites-available w ogóle nie istnieje. Plik trafia bezpośrednio do /etc/nginx/conf.d/app.conf i jest przez to od razu aktywny.
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
Aktywacja na Debianie i Ubuntu:
ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
nginx -t przed każdym przeładowaniem to nie uprzejmość, tylko obowiązek. systemctl reload z błędną konfiguracją pozostawi wprawdzie stary proces przy życiu, ale późniejszy restart serwera nie podniesie już nginx w ogóle.
Wszystkie polecenia w tej sekcji zakładają uprawnienia roota, w przeciwnym razie dopisz z przodu sudo. Dotyczy to również samych poleceń sprawdzających: nginx -t oraz nginx -T uruchomione jako zwykły użytkownik nie potykają się o konfigurację, tylko o plik, do którego nie mają prawa zapisu. Komunikat [emerg] open() "/run/nginx.pid" failed (13: Permission denied), a po nim configuration file /etc/nginx/nginx.conf test failed, nie oznacza więc, że twoja konfiguracja jest zepsuta.
Ta konfiguracja przekazuje ruch dalej. Mimo to jest zepsuta, i to w sposób, który ujawnia się dopiero później.
Cztery nagłówki, bez których aplikacja jest ślepa
Te cztery wiersze należą do każdego bloku location z proxy_pass:
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
Bez tego wiersza nginx ustawia Host: 127.0.0.1:3000. Objawy: Django odpowiada komunikatem Invalid HTTP_HOST header i zwraca 400. Laravel i WordPress generują bezwzględne adresy URL wskazujące na 127.0.0.1, widoczne w przekierowaniach i w każdej wysłanej wiadomości. Backend obsługujący kilka domen zawsze poda treści niewłaściwego tenanta.
Właściwym wyborem jest przy tym $host, a nie $http_host: $host zawiera nazwę hosta bez portu i wraca do wartości server_name, gdy klient w ogóle nie wyśle nagłówka Host. $http_host przepuszcza dalej to, co przyszło, łącznie z numerem portu.
X-Real-IP
$remote_addr to adres IP, z którym nginx faktycznie rozmawia. Dokładnie jeden adres, żadnego przecinka, żadnego parsowania. Dla aplikacji, które znają tylko jedno pole na adres IP klienta, to najprostsza droga.
X-Forwarded-For
$proxy_add_x_forwarded_for bierze ewentualnie już istniejący nagłówek X-Forwarded-For i dokleja $remote_addr z prawej strony. Przy kilku proxy powstaje w ten sposób łańcuch.
I tu kryje się luka bezpieczeństwa, o której nie wspomina prawie żaden poradnik: klient może sam dołączyć własny nagłówek X-Forwarded-For. Jeśli twój nginx stoi bezpośrednio w internecie, $proxy_add_x_forwarded_for zlepia zmyśloną wartość podaną przez atakującego i prawdziwy adres IP klienta we wspólną listę. Kiedy twoja aplikacja odczyta jako adres klienta pierwszy wpis, uwierzy w dowolny adres. W ten sposób da się obejść limity żądań, blokady adresów IP i logikę geolokalizacji.
Wynikają z tego dwa jasne wnioski:
- Łańcuch czyta się zawsze od prawej. Ostatni wpis jest jedynym, który zapisało twoje własne proxy.
- Jeśli nginx jest jedyną instancją przed aplikacją, lepiej nadpisz cały łańcuch, zamiast go uzupełniać:
proxy_set_header X-Forwarded-For $remote_addr;
W ten sposób znika cała sfałszowana historia. $proxy_add_x_forwarded_for jest właściwe tylko wtedy, gdy przed nginx stoi jeszcze load balancer albo CDN, któremu naprawdę ufasz. W takim przypadku trzeba dodatkowo skonfigurować moduł realip, żeby prawdziwy adres znał już sam nginx i nie zapisywał w logu adresu poprzednika:
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Wpis set_real_ip_from jest listą dozwolonych adresów. Bez niego moduł nie działa, a przy zbyt szerokim zakresie w rodzaju 0.0.0.0/0 staje się otwartymi drzwiami.
X-Forwarded-Proto
Aplikacja widzi zwykłe połączenie HTTP niezależnie od tego, co dzieje się na zewnątrz. Gdy tego nagłówka brakuje, dzieje się zwykle tak: aplikacja stwierdza „brak HTTPS” i przekierowuje na HTTPS, nginx przyjmuje żądanie, odszyfrowuje je, przekazuje dalej znowu jako HTTP, a aplikacja przekierowuje ponownie. Przeglądarka zgłasza ERR_TOO_MANY_REDIRECTS. Równie często zdarza się, że ciasteczka z flagą Secure nie zostają ustawione, logowanie kończy się niepowodzeniem bez żadnego komunikatu, a strony ładują obrazki i skrypty przez http://, co przeglądarka blokuje jako mixed content.
Używaj $scheme, a nie sztywnej wartości "https". Inaczej także blok na porcie 80 będzie twierdził, że połączenie było szyfrowane.
Dowód na to, że nagłówki naprawdę docierają
Zamiast zgadywać, zbuduj sobie lustro. Ten dodatkowy blok server odpowiada na każde żądanie otrzymanymi nagłówkami zapisanymi zwykłym tekstem:
server {
listen 127.0.0.1:9999;
default_type text/plain;
location / {
return 200 "Host: $host\nX-Real-IP: $http_x_real_ip\nX-Forwarded-For: $http_x_forwarded_for\nX-Forwarded-Proto: $http_x_forwarded_proto\nProtokół: $server_protocol\n";
}
}
Skieruj testowo proxy_pass na http://127.0.0.1:9999, przeładuj nginx i otwórz stronę. To, co się tam pojawi, jest dokładnie tym, co w innym wypadku dostałaby twoja aplikacja. Jeśli wszystkie cztery wiersze są wypełnione, a adres IP zgadza się z twoim prawdziwym łączem, konfiguracja jest w porządku. Potem usuń blok testowy.
Aplikacja musi jednak te nagłówki także odczytywać. Express potrzebuje app.set('trust proxy', 1), Symfony ustawienia trusted_proxies, a Django USE_X_FORWARDED_HOST oraz SECURE_PROXY_SSL_HEADER. Bez tego przełącznika frameworki celowo ignorują nagłówki, dokładnie z opisanego wyżej powodu, czyli z obawy przed podszywaniem się.
proxy_pass i ukośnik, który zmienia wszystko
Najczęstszy cichy błąd w całej konfiguracji nginx. Jeden jedyny znak decyduje o ścieżce, która dociera do backendu.
| Konfiguracja | Żądanie | Backend otrzymuje |
|---|---|---|
location /api/ { proxy_pass http://127.0.0.1:3000; } | /api/users | /api/users |
location /api/ { proxy_pass http://127.0.0.1:3000/; } | /api/users | /users |
location /api/ { proxy_pass http://127.0.0.1:3000/v2/; } | /api/users | /v2/users |
Zasada: gdy tylko za hostem i portem stoi jakakolwiek ścieżka, choćby sam ukośnik, nginx zastępuje tę część adresu URL, która pasuje do prefiksu z location. Bez podanej ścieżki cały adres URL zostaje przekazany bez zmian.
Obraz błędu jest charakterystyczny: strona startowa działa, ale wszystko poniżej prefiksu zwraca 404, a w logu backendu widnieją ścieżki z podwojonym prefiksem w rodzaju /api/api/users. Rzut oka do logu aplikacji wyjaśnia to w kilka sekund, a zgadywanie po stronie nginx kosztuje godziny.
Dwa przypadki szczególne, które dają własne komunikaty błędów. W bloku location z wyrażeniem regularnym podanie ścieżki jest zabronione, inaczej nginx przerwie start komunikatem nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, inside named location, or inside "if" statement. A gdy tylko użyjesz w proxy_pass zmiennej, na przykład proxy_pass http://$backend;, nginx przestaje rozwiązywać nazwę przy starcie i robi to dopiero w czasie działania. Bez wiersza resolver w bloku server kończy się to komunikatem no resolver defined to resolve ... i błędem 502.
Przekazywanie WebSocketów
nginx rozmawia z backendem domyślnie po HTTP/1.0. HTTP/1.0 nie zna mechanizmu upgrade. Dlatego każdy WebSocket rozbija się o podstawową konfigurację, niezależnie od tego, jak poprawna jest cała reszta.
Typowe objawy: konsola przeglądarki zgłasza WebSocket connection to 'wss://app.example.com/ws' failed, często z dopiskiem Error during WebSocket handshake: Unexpected response code: 400. Socket.io po cichu wraca do long pollingu, aplikacja sprawia tylko wrażenie ociężałej, a w logu dostępu w nieskończoność powtarzają się wiersze z /socket.io/?EIO=4&transport=polling.
Najpierw mapowanie, które decyduje o tym, czy połączenie w ogóle chce upgrade. Należy ono do kontekstu http, więc najlepiej do osobnego pliku /etc/nginx/conf.d/websocket.conf:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Jeśli ten blok trafi przez pomyłkę do bloku server albo location, nginx przestanie startować: nginx: [emerg] "map" directive is not allowed here.
Następnie w bloku location:
location / {
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_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
Dlaczego mapowanie, a nie po prostu proxy_set_header Connection "upgrade";? Bo wtedy upgrade zażądają także całkiem zwyczajne żądania HTTP. Część backendów odpowiada na to kodem 400, a ponowne wykorzystanie połączeń keepalive przepada. Mapowanie wysyła upgrade tylko wtedy, gdy klient faktycznie o niego poprosił, a w pozostałych przypadkach close.
Test: żądanie WebSocket musi pokazać w narzędziach dla programistów przeglądarki status 101 Switching Protocols. Wszystko inne, w szczególności 200 albo 400, oznacza, że upgrade nie przeszedł. W wierszu poleceń da się to sprawdzić także bez przeglądarki:
curl -sSi -o /dev/null -w '%{http_code}\n' \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://127.0.0.1/ws
Poprawne ustawianie limitów czasu
Jeśli połączenie zrywa się powtarzalnie po dokładnie 60 sekundach, to nie przypadek, tylko domyślna wartość proxy_read_timeout. Ważne dla zrozumienia: wartość ta nie ogranicza łącznego czasu trwania żądania, tylko przerwę między dwoma odczytami. Pobieranie, które trwa dziesięć minut, ale bez przerwy dostarcza dane, przejdzie bez problemu. WebSocket, na którym przez 61 sekund nic się nie dzieje, zostanie wyrzucony.
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_connect_timeout dotyczy wyłącznie nawiązania połączenia i jest ograniczony do 75 sekund. Ustawianie wyższej wartości nic nie da. Przy lokalnym backendzie 10 sekund to i tak dużo, a niska wartość pozwala szybciej zauważyć, że usługa w ogóle nie działa.
Przy WebSocketach lepszą drogą nie jest proxy_read_timeout 86400s;, tylko heartbeat w aplikacji, który co 30 sekund wysyła pinga. Dzięki temu limit czasu zostaje zachowany jako zabezpieczenie przed zawieszonymi połączeniami, zamiast być faktycznie wyłączonym.
Dwie kolejne wartości domyślne, które regularnie dają o sobie znać. client_max_body_size wynosi 1 MB, więc każde większe wysłanie pliku kończy się błędem 413 Request Entity Too Large i wierszem w logu client intended to send too large body. Z kolei przy Server-Sent Events albo odpowiedziach strumieniowych do odwiedzającego długo nic nie dociera, bo nginx buforuje. Wtedy pomaga proxy_buffering off; w danym bloku location, punktowo, a nie globalnie.
HTTPS przed aplikacją
Najwygodniejszą drogą jest certbot z wtyczką nginx. Czyta on istniejący blok server, uzupełnia część TLS i konfiguruje odnawianie certyfikatu:
apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d app.example.com
Te nazwy pakietów obowiązują na Debianie i Ubuntu. Na AlmaLinuksie, Rocky Linuksie i Oracle Linuksie certbot nie leży w podstawowych repozytoriach, a samo dnf install -y certbot python3-certbot-nginx kończy się tam komunikatem Error: Unable to find a match. EPEL jest bezwzględnie konieczny:
dnf install -y epel-release
dnf install -y certbot python3-certbot-nginx
Na Oracle Linuksie 9 pakiet EPEL nazywa się oracle-epel-release-el9, a repozytorium trzeba w razie potrzeby wcześniej włączyć poleceniem dnf config-manager --enable ol9_developer_EPEL.
Przy wielu subdomenach warto zajrzeć do tekstu o certyfikatach wildcard od Let's Encrypt.
Różnica wersji, która przy kopiowaniu cudzych konfiguracji generuje ostrzeżenia: do nginx 1.24 HTTP/2 włącza się w wierszu listen, a od nginx 1.25.1 służy do tego osobna dyrektywa. Debian 13 dostarcza nginx 1.26.3 i chce nowego zapisu, natomiast Debian 12 (1.22.1), Ubuntu 24.04 (1.24.0) oraz Ubuntu 22.04 (1.18.0) starego. Po stronie Red Hata granica przebiega tak samo: AlmaLinux 10 przynosi 1.26.3, a więc nową formę, a AlmaLinux 9, Rocky Linux 9 i Oracle Linux 9 przynoszą 1.20.1 i potrzebują starej.
# nginx od 1.25.1, czyli między innymi Debian 13
listen 443 ssl;
http2 on;
# nginx do 1.24, czyli Debian 12, Ubuntu 24.04 i 22.04
listen 443 ssl http2;
Jeśli użyjesz starej formy na nowym nginx, nginx -t zgłosi: nginx: [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead. To tylko ostrzeżenie, więc wszystko działa dalej. Nowa forma na starym nginx to natomiast twardy błąd startu: unknown directive "http2".
Na koniec najważniejszy punkt całego ćwiczenia: backend nie może sam wisieć w internecie. Reverse proxy na nic się nie zda, jeśli http://server-ip:3000 nadal jest bezpośrednio osiągalne, bo wtedy każdy może sam ustawić nagłówki tak, jak mu pasuje. Zwiąż usługę z adresem 127.0.0.1.
W przypadku Dockera to szczególnie ostra pułapka: -p 3000:3000 publikuje port na wszystkich adresach i wpisuje przy tym reguły, które po prostu omijają firewalla ufw. Poprawny zapis to -p 127.0.0.1:3000:3000. Szczegóły konfiguracji znajdziesz w tekście instalacja Dockera na Debianie i Ubuntu.
Jeśli wyjątkowo kierujesz proxy do backendu po HTTPS, nginx potrzebuje dodatkowego wiersza, inaczej nie wyśle nazwy SNI, a druga strona poda niewłaściwy certyfikat:
proxy_pass https://backend.intern:8443;
proxy_ssl_server_name on;
Kiedy coś pójdzie nie tak: komunikaty w oryginalnym brzmieniu
Pierwszym miejscem, do którego zaglądasz, jest zawsze tail -f /var/log/nginx/error.log. Strona błędu w przeglądarce nie mówi nic, log mówi wszystko.
Jeszcze drobiazg na początek, żeby pierwszy wiersz nie wprowadził w błąd: na AlmaLinuksie, Rocky Linuksie i Oracle Linuksie plik /var/log/nginx/error.log zaraz po instalacji jeszcze w ogóle nie istnieje, powstaje dopiero przy pierwszym starcie nginx. tail odpowiada wtedy komunikatem cannot open ... No such file or directory. Na Debianie i Ubuntu pakiet zakłada access.log oraz error.log już podczas instalacji. Wariant odporny na oba światy:
tail -n 50 /var/log/nginx/error.log 2>/dev/null || journalctl -u nginx -n 50 --no-pager
| Komunikat w error.log | Znaczenie i sposób naprawy |
|---|---|
connect() failed (111: Connection refused) while connecting to upstream | Nic nie nasłuchuje na docelowym porcie. Sprawdź stan usługi i skontroluj poleceniem ss -ltnp, czy port i adres zgadzają się z wierszem proxy_pass. Jeśli brakuje ss, na Debianie i Ubuntu dostarcza je pakiet iproute2, a w rodzinie Red Hata pakiet iproute (tam bez dwójki w nazwie). |
connect() failed (113: No route to host) | Blokuje firewall między nginx a backendem. Przy kontenerach chodzi często o niewłaściwą sieć. |
connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstream | Na AlmaLinuksie, Rocky, RHEL-u i Oracle Linuksie prawie zawsze SELinux. Dowód przez ausearch -m AVC -ts recent, naprawa przez setsebool -P httpd_can_network_connect 1. Na Debianie i Ubuntu to nie występuje. |
upstream timed out (110: Connection timed out) while reading response header from upstream | Daje 504. Backend odpowiada zbyt wolno. Najpierw zajrzyj tam, a dopiero potem podnoś proxy_read_timeout. |
upstream prematurely closed connection while reading response header | Daje 502. Proces backendu padł w trakcie żądania, często za sprawą OOM killera. Zobacz konfigurację swapa. |
upstream sent too big header while reading response header from upstream | Zbyt duże nagłówki odpowiedzi, klasycznie przy licznych albo długich ciasteczkach. Ustaw proxy_buffer_size 32k; oraz proxy_buffers 8 32k;. |
no live upstreams while connecting to upstream | W bloku upstream wszystkie cele zostały oznaczone jako niedostępne. Zachowaniem przy sprawdzaniu kondycji sterują max_fails i fail_timeout. |
Dla przypadków szczególnych wokół błędu 502 istnieje osobny poradnik: naprawa błędu nginx 502 Bad Gateway. Jeśli twój backend nie jest jeszcze porządnie startującą usługą, warto wcześniej utworzyć serwis systemd.
Po czym poznasz, że wszystko naprawdę działa
Pięć sprawdzeń, które razem dają wiarygodny obraz:
nginx -tzgłaszasyntax is okoraztest is successful.nginx -Tpokazuje kompletną, złożoną konfigurację. Poszukaj w niejproxy_set_headeri policz: wszystkie cztery nagłówki muszą stać w każdym istotnym bloku location. Jednoproxy_set_headerw bloku wewnętrznym kasuje wszystkie odziedziczone nagłówki bloku zewnętrznego, a to najczęstsza przyczyna okrzyku „przecież to ustawiłem”.- W logu aplikacji widnieje prawdziwy adres IP odwiedzającego, a nie
127.0.0.1. - Opisany wyżej blok server z lustrem zwraca wszystkie cztery wartości wypełnione, z
X-Forwarded-Proto: httpsprzy wywołaniu przez HTTPS. - Dla WebSocketów: status 101 w narzędziach dla programistów, a połączenie przeżywa ponad 60 sekund ciszy.
Pomocny jest poza tym format logu, który zapisuje przekazywany adres IP. Od razu widzisz, czy nginx i aplikacja mają na myśli ten sam adres:
log_format proxied '$remote_addr xff="$http_x_forwarded_for" '
'host=$host "$request" $status $body_bytes_sent '
'upstream=$upstream_addr rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/app.access.log proxied;
Obie wartości czasu na końcu są na wagę złota: $request_time to łączny czas z punktu widzenia odwiedzającego, a $upstream_response_time czas odpowiedzi backendu. Jeśli obie leżą blisko siebie, wolny jest backend. Jeśli rozwiera się między nimi luka, winne jest łącze do klienta albo buforowanie.
W ten sposób powstaje reverse proxy, które nie tylko udostępnia aplikację, ale też przekazuje jej wszystko, co musi wiedzieć o swoich odwiedzających. Jeśli stawiasz tę konfigurację na świeżym systemie, dobrym punktem wyjścia do wszystkiego, co trzeba zrobić wcześniej, jest lista kontrolna dla nowych serwerów root.
Najczęstsze pytania
Dlaczego moja aplikacja widzi 127.0.0.1 zamiast prawdziwego adresu IP odwiedzającego?
Czym różni się X-Real-IP od X-Forwarded-For?
Dlaczego moje WebSockety nie działają za nginx?
Dlaczego moje połączenie zrywa się zawsze po dokładnie 60 sekundach?
Co zmienia ukośnik na końcu proxy_pass?
Dlaczego na AlmaLinuksie dostaję 502 z komunikatem Permission denied?
2026 KernelHost GmbH. Wszelkie prawa zastrzeżone. Ten poradnik jest chroniony prawem autorskim. Publikowanie go w innych serwisach, w całości, we fragmentach lub w zmienionej formie, wymaga naszej pisemnej zgody. Cytaty z podaniem źródła i z linkiem są jak najbardziej mile widziane.

