---
title: "Деплой: chi за reverse-прокси"
description: "Деплой Go-сервиса на chi за nginx: заголовки X-Forwarded-For и middleware.RealIP, таймауты прокси и http.Server, multistage Dockerfile и systemd-юнит."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/23-deploy/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

# Деплой: chi за reverse-прокси

*Курс «Chi: роутер для Go HTTP-сервисов», урок 23 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/23-deploy/*

Деплой Go-сервиса на chi редко заканчивается go build: между интернетом и процессом встаёт reverse-прокси, и после выката за nginx журнал показывает один адрес прокси, а лимит по IP душит всех разом. Дальше по слоям: заголовки X-Forwarded-For и X-Real-IP, middleware.RealIP, X-Forwarded-Proto для редиректов, таймауты nginx и http.Server, multistage-образ и systemd-юнит — и живое место про подделанный заголовок.

Первый же выкат Go-сервиса на chi за nginx ломает привычную картину: в журнале на каждый запрос один и тот же адрес — 172.17.0.1 или адрес прокси, лимит запросов режет всех пользователей одной очередью, а редирект на страницу логина уводит с https:// на http://. Ничего в chi не сломалось: сервис честно видит то, что ему принёс прокси — TCP-соединение от nginx, а не от клиента. Деплой go сервиса — это два слоя, и настраивать их надо согласованно: прокси принимает TLS, сжимает ответы, отдаёт статику и переводит запрос на chi, а chi должен узнать настоящий адрес клиента и исходную схему из заголовков. Ниже — раскладка по слоям: что настраиваем в nginx, что в chi, где живут таймауты, куда ставить multistage-образ и systemd-юнит, и живое место про подделанный X-Forwarded-For, из-за которого лимиты по IP облетают стороной.

### Деплой go сервиса за nginx: слои от TLS до chi

Обязанности раскладываются так. Прокси держит TLS-терминацию: сертификаты, redirect с 80-го порта, http2 — всё здесь, chi работает по HTTP внутри доверенного периметра. Сжатие — gzip на прокси: жать в хендлере значит тратить CPU приложения на работу, которую прокси делает дешевле. Статика — images и собранные ассеты отдаёт nginx, chi занимается только API: [файловый сервер chi через Mount](/courses/chi/lessons/08-mount-statica/) остаётся для dev-режима и внутренних задач. Балансировка — upstream с несколькими инстансами chi, когда один перестал справляться. Плюс мелочи, которые в приложении делать дорого: ограничение соединений, чёрные списки, access-лог. chi за этим остаётся ровно тем, чем был весь курс: роутером и хендлерами на чистом net/http. Двусторонняя связь одна и простая: заголовки от прокси к chi и health-пробы от прокси к /healthz.

### Dockerfile для Go: multistage и distroless

Go-бинарник — редкий случай, когда контейнер может быть крошечным: сборке нужен тулчейн, запуску — нет. Multistage-сборка разделяет эти жизни: первая стадия FROM golang:1.22-alpine качает модули и компилирует, вторая FROM alpine (или пустой FROM scratch, или distroless) получает один файл через COPY --from=build. CGO_ENABLED=0 выключает cgo — бинарник становится статическим и запускается даже в scratch без libc. В финальном образе нет компилятора, модулей и shell — меньше поверхность атаки и мегабайты. EXPOSE 8080 — только документация порта, проброс делает -p при запуске. Health-check: /healthz из каркаса с graceful shutdown опрашивается прокси и оркестратором; HEALTHCHECK в Dockerfile требует исполняемый файл, а в distroless нет ни shell, ни curl — проверку переносят в docker-compose или Kubernetes либо копируют рядом крошечный check-бинарник.

### Multistage-образ для сервиса на chi

```go
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/notes ./cmd/notes

FROM alpine:3.20
COPY --from=build /bin/notes /notes
EXPOSE 8080
ENTRYPOINT ["/notes"]
```

Слои с go.mod и go.sum идут раньше кода: пока файлы модулей не менялись, docker build переиспользует кэш и не качает зависимости на каждый коммит. Финальная стадия забирает только бинарник — тулчейн, исходники и кэш модулей остаются в build-стадии. Docker stop посылает SIGTERM, который ловит каркас из урока про graceful shutdown. Smoke-тест бинарника удобно прогнать локальным Go до сборки образа: go build && ./notes и curl на /healthz.

### Проверка: что попадает в финальный образ

**Вопрос:** Что именно копирует в финальную стадию строка COPY --from=build /bin/notes /notes?

- ✅ Только скомпилированный бинарник: тулчейн и модули остаются в build-стадии
- Весь каталог /src вместе с исходниками и go.sum
- Кэш модулей из pkg/mod, чтобы зависимости были под рукой
- Бинарник и компилятор go для рантайм-сборки плагинов

COPY --from=build переносит в финальный образ только указанный файл: компилятор, исходники и кэш модулей остаются внутри build-стадии и в образ не попадают. Отсюда разница в весе: полная golang-стадия — сотни мегабайт, финальный alpine — десятки, scratch или distroless — единицы.

### X-Forwarded-For и middleware.RealIP в chi

Адрес клиента живёт в заголовках, которые выставляет прокси. X-Real-IP — один адрес, тот, что прокси видел на своём входе. X-Forwarded-For — цепочка через запятую, где каждый прокси дописывает свой прыжок. X-Forwarded-Proto — исходная схема, http или https. Важная деталь nginx: по умолчанию он передаёт на бэкенд только Host и Connection — все три заголовка надо выставить proxy_set_header вручную. В chi адрес чинит middleware.RealIP. По исходникам v5.0.8 он проверяет заголовки в порядке True-Client-IP, X-Real-IP, X-Forwarded-For, у цепочки берёт первое значение до запятой, валидирует строку через net.ParseIP — мусор молча игнорируется — и переписывает r.RemoteAddr. Ставить слой надо рано, до Logger: адрес в строке лога читается из RemoteAddr в момент сборки, и порядок слоёв из урока про RequestID и Logger работает и здесь.

### nginx перед chi: location с заголовками

```go
server {
    listen 443 ssl;
    location / {
        proxy_pass http://127.0.0.1:8080;
        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 30s;
    }
}
```

proxy_pass переводит запрос на chi, слушающий 127.0.0.1:8080 — снаружи порт не открыт, до RealIP добирается только nginx. X-Forwarded-For здесь затирается адресом клиента, а не дополняется: почему — в красной врезке ниже. X-Forwarded-Proto отдаёт исходную схему, и редиректы перестают уводить с https. Проверка руками: chi локальным Go на :8080, nginx на :8081, curl -H «X-Forwarded-For: 203.0.113.9» — хендлер увидит честный RemoteAddr.

> **RealIP без границы: клиент подделал X-Forwarded-For.** Заголовок клиент шлёт сам: curl -H «X-Forwarded-For: 1.2.3.4» — и nginx с классическим proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for дописывает реальный адрес в конец: «1.2.3.4, 91.108.x.x». chi берёт первое значение — подделку. Rate-limit по IP становится бесполезен: новый фейковый адрес на каждый запрос, лимиты обходятся бесплатно, логи врут. Фикс — на границе: затирать клиентский заголовок значением $remote_addr, как в конфиге выше, и не открывать порт chi наружу. Слой RealIP доверяет тому, кто стоит перед ним, и ничему больше.

### Проверка: что окажется в RemoteAddr

**Вопрос:** За nginx без proxy_set_header идёт запрос с клиентским заголовком «X-Forwarded-For: 1.2.3.4». На chi включён middleware.RealIP. Что в r.RemoteAddr?

- ✅ 1.2.3.4: RealIP молча поверил клиентскому заголовку
- Адрес nginx: RealIP знает, где кончается доверенная зона
- Пустая строка: клиентские заголовки RealIP игнорирует
- 1.2.3.4 с ошибкой, потому что адрес не из приватной сети

RealIP в chi не отличает свой прокси от клиента: он берёт первое значение из X-Forwarded-For и проверяет только формат через net.ParseIP. Если прокси не перезаписал заголовок — а до сервиса клиент добрался напрямую, минуя границу, — подделка доехала до RemoteAddr. Доверие обеспечивает сетевая граница, не код слоя.

### Какой адрес выберет RealIP?

```go
// заголовки запроса, дошедшего до chi:
X-Real-IP: 203.0.113.7
X-Forwarded-For: 198.51.100.9, 10.0.0.5

// стек: r.Use(middleware.RealIP)
// в хендлере:
fmt.Print(r.RemoteAddr)
```

**Что выведет программа?**

- ✅ 203.0.113.7: X-Real-IP проверяется раньше X-Forwarded-For
- 198.51.100.9, 10.0.0.5: вся цепочка X-Forwarded-For целиком
- 198.51.100.9: первый элемент X-Forwarded-For
- 203.0.113.7:443: адрес переписывается вместе с портом

Порядок в исходниках v5.0.8: True-Client-IP, затем X-Real-IP, и только потом X-Forwarded-For — у цепочки берётся значение до первой запятой. RemoteAddr получает голый IP без порта. Первым элементом XFF был бы 198.51.100.9, но очередь до него не доходит: X-Real-IP уже непустой.

### X-Forwarded-Proto и таймауты прокси и http.Server

Схема нужна каждому, кто строит абсолютные URL или решает, редиректить ли на https: за прокси r.TLS всегда nil — соединение к chi идёт по HTTP, и проверка «r.TLS == nil» врёт. Отсюда конвенция: прокси выставляет X-Forwarded-Proto $scheme, приложение читает заголовок — и redirect на логин остаётся на https://. Второй слой согласования — таймауты. У nginx свой бюджет: proxy_connect_timeout, proxy_send_timeout и proxy_read_timeout, по умолчанию 60 секунд каждый; в http.Server — ReadTimeout, WriteTimeout и IdleTimeout из каркаса. Правило: дедлайн chi должен срабатывать раньше бюджета прокси. Timeout-хендлер на 5 секунд при proxy_read_timeout 60s — нормально; отчёт на 90 секунд при прокси 60s — прокси отрежет соединение, клиент увидит 502, а хендлер доработает впустую. [Дедлайны и троттлинг](/courses/chi/lessons/13-timeout-throttle/) выставляются с учётом прокси-бюджета, а не в отрыве от него.

### systemd без Docker: unit-файл

Docker — не единственный способ жить на сервере, и для одиночного VPS systemd честно проще: один бинарник, unit-файл, никакого реестра образов. ExecStart указывает на собранный бинарник, User — на непривилегированного пользователя (root не нужен: chi слушает порт выше 1024, а 80/443 держит nginx), Restart=on-failure поднимает процесс после падения, EnvironmentFile подкладывает переменные окружения. systemctl enable --now notes — сервис в автозапуске; journalctl -u notes — логи. Docker stop и systemctl stop делают одно и то же: шлют SIGTERM, который ловит graceful shutdown из каркаса — долетающие запросы заканчиваются, новые не принимаются. Чего в systemd-варианте нет — изоляции и воспроизводимости: версии libc и окружение зависят от машины, и собирать бинарник нужно на совместимой системе.

### Unit-файл для сервиса на chi

```go
[Unit]
Description=notes-api (chi)
After=network-online.target

[Service]
User=notes
ExecStart=/opt/notes/notes
EnvironmentFile=/etc/notes/env
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
```

After=network-online.target откладывает старт до появления сети — слушателю нужен интерфейс. User=notes: сервис работает от непривилегированного пользователя с правами только на бинарник и env-файл. Restart=on-failure с RestartSec=2 — пауза между попытками, чтобы не крутить бесконечный рестарт-цикл при битой конфигурации. После правки юнита — systemctl daemon-reload, иначе systemd продолжит держать старую версию файла в памяти.

> **Спор: nginx против Caddy против чистого Go с TLS.** nginx — когда сервисов много и ops-традиции уже сложились: гибкие location, upstream, экосистема модулей; цена — многословный конфиг и сертификаты руками или через certbot. Caddy — когда нужен короткий конфиг и авто-TLS из коробки: пара строк — и сертификаты обновляются сами; цена — меньше гибкости и меньше людей, которые его знают. Чистый Go с autocert — минус целый слой: меньше точек отказа, но gzip, лимиты соединений и статику придётся держать в приложении, и любой баг хендлера виден снаружи. Расклад у меня такой: один pet-сервис — Caddy или autocert; прод с несколькими сервисами — nginx, потому что граница «сеть отдельно от кода» окупается с первого инцидента.

### Слой → что настраиваем

| Слой | Где настраиваем | Что делаем |
| --- | --- | --- |
| TLS | nginx / Caddy | сертификаты, redirect с 80 на 443, http2 |
| Сжатие | nginx / Caddy | gzip для text/*: JSON и HTML, без уже сжатых картинок |
| Статика | nginx / Caddy | location на каталог ассетов и cache-заголовки |
| Заголовки клиента | nginx → chi | X-Real-IP, X-Forwarded-For, X-Forwarded-Proto через proxy_set_header |
| Адрес клиента | chi | middleware.RealIP в начале стека, до Logger |
| Таймауты | nginx + http.Server | proxy_read_timeout больше дедлайнов хендлеров chi |
| Перезапуск | systemd / Docker | Restart=on-failure или restart-политика контейнера |

### Чек-лист выката

- proxy_set_header четвёркой: Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto — nginx по умолчанию передаёт только Host и Connection.
- X-Forwarded-For затирается $remote_addr, а не дополняется $proxy_add_x_forwarded_for — иначе клиент подделает адрес.
- middleware.RealIP стоит в начале стека, до Logger; порт chi снаружи закрыт.
- Таймауты согласованы: proxy_read_timeout больше дедлайнов chi, иначе долгие запросы умрут 502-ми на прокси.
- /healthz отвечает и в момент graceful shutdown — прокси снимает инстанс с балансировки до остановки.
- Restart=on-failure в systemd или restart-политика в Docker — падение не оставляет сервис мёртвым до утра.

### Восстановите location перед chi

Три строки nginx-конфига: порт бэкенда, адрес клиента и исходная схема.

```go
location / {
    proxy_pass http://127.0.0.1:___;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $___;
    proxy_set_header X-Forwarded-Proto $___;
}
```

Подсказка: Порт chi из конфига выше; remote_addr — с кем nginx говорит по TCP; scheme — http или https, как пришёл запрос.

Правильные ответы в пропусках: `8080`, `remote_addr`, `scheme`

### Соберите multistage-файл

Расставьте строки Dockerfile: от базового образа сборки до переноса бинарника в финальную стадию.

Правильный порядок строк:

1. `FROM golang:1.22-alpine AS build`
2. `WORKDIR /src`
3. `COPY go.mod go.sum ./`
4. `RUN go mod download`
5. `RUN CGO_ENABLED=0 go build -o /bin/notes ./cmd/notes`
6. `COPY --from=build /bin/notes /notes`

### Карточки: chi за прокси

- **X-Forwarded-For** — Цепочка адресов через запятую: каждый прокси дописывает свой прыжок. chi берёт первое значение до запятой. Подделывается клиентом, если прокси не затирает заголовок.
- **X-Real-IP** — Один адрес — тот, что прокси видел на входе. В chi проверяется раньше X-Forwarded-For. Выставляется proxy_set_header X-Real-IP $remote_addr.
- **middleware.RealIP** — Переписывает r.RemoteAddr по True-Client-IP → X-Real-IP → X-Forwarded-For, валидирует через net.ParseIP и подставляет голый IP без порта. Только за доверенным прокси.
- **X-Forwarded-Proto** — Исходная схема запроса: за прокси r.TLS всегда nil, схему для редиректов берут из этого заголовка. nginx: proxy_set_header X-Forwarded-Proto $scheme.
- **Multistage-образ** — Сборка в стадии golang, финал — alpine, scratch или distroless с одним бинарником через COPY --from=build. CGO_ENABLED=0 — статический бинарник без libc.
- **proxy_read_timeout** — Бюджет nginx на чтение ответа бэкенда, по умолчанию 60 секунд. Должен быть больше дедлайнов хендлеров chi — иначе прокси отрежет долгий запрос с 502.

Сервис за прокси перестаёт быть игрушкой на ноутбуке: TLS и сжатие съел nginx, адрес клиента доехал через X-Real-IP и RealIP до RemoteAddr, редиректы держат схему из X-Forwarded-Proto, таймауты согласованы, а падение поднимает Restart. Остаётся выключаться вежливо: graceful shutdown из [отдельного урока](/courses/chi/lessons/16-graceful/) уже ловит SIGTERM от systemd и Docker stop, а /healthz держит прокси в курсе живости инстанса. Бюджеты по времени выставляются вместе с Timeout и Throttle: сначала дедлайн chi, потом прокси-запас. Полная карта курса — на [странице chi](/courses/chi/).

## Коротко: что запомнить

- Схема «nginx/Caddy → chi»: TLS, сжатие и статика на прокси, chi занимается логикой на чистом net/http.
- nginx по умолчанию передаёт только Host и Connection — X-Real-IP, X-Forwarded-For и X-Forwarded-Proto выставляются proxy_set_header вручную.
- middleware.RealIP переписывает RemoteAddr по True-Client-IP → X-Real-IP → X-Forwarded-For и безопасен только за доверенным прокси.
- Подделанный X-Forwarded-For при $proxy_add_x_forwarded_for обходит лимиты по IP: заголовок затирается на границе, порт chi закрыт снаружи.
- Таймауты согласованы: дедлайны chi срабатывают раньше proxy_read_timeout — иначе прокси отрезает долгие запросы 502-ми.
- Multistage-образ: сборка в полной стадии, в финал копируется один статический бинарник; health-check — /healthz плюс оркестратор.
- Без Docker — systemd: ExecStart, User, Restart=on-failure, EnvironmentFile и SIGTERM для graceful shutdown.

## Домашнее задание

Соберите multistage-образ сервиса: build-стадия с CGO_ENABLED=0, финальная на alpine, EXPOSE и ENTRYPOINT. Рядом напишите nginx-location с четвёркой proxy_set_header — X-Forwarded-For затирается $remote_addr — и unit-файл systemd с Restart=on-failure. Проверьте curl-ом с подделанным X-Forwarded-For, что в RemoteAddr оказался честный адрес клиента.

## Вопросы и ответы по уроку

### Нужен ли reverse-прокси, если chi сам умеет таймауты и статику?

Умеет — но каждый слой, который вы тащите в приложение, съедает его CPU и появляется в коде. Прокси снимает TLS, сжатие и лимиты соединений до chi и даёт точку, где сеть настраивается без пересборки бинарника. Для pet-проекта чистый Go с autocert допустим; прод обычно всё равно заканчивается прокси.

### Почему после middleware.RealIP в RemoteAddr нет порта?

chi v5 подставляет в RemoteAddr значение из заголовка как есть — голый IP без порта, в отличие от обычного «IP:port» от TCP-соединения. Код, который разбирает адрес через net.SplitHostPort, начнёт получать ошибку «missing port». Валидация у chi есть — net.ParseIP отсекает мусор, — но порт слой не дописывает.

### X-Real-IP или X-Forwarded-For — что безопаснее?

Одиночный X-Real-IP от собственного прокси проще: в нём одно значение, и его легко затереть на границе. X-Forwarded-For рассчитан на цепочки прокси, а chi берёт из него первый элемент — тот, что ближе к клиенту. Пока за вами один nginx, безопаснее X-Real-IP или затёртый XFF; цепочке нужен доверенный каждый прыжок.
