Abaixo vai um template com boas práticas para servir um build React com nginx:1.29.4-alpine, sem root em runtime, com logs acessíveis, e com config por variáveis de ambiente para apontar para um backend externo.
Objetivo técnico
Situação: React gera arquivos estáticos (build/), Nginx serve esses arquivos e faz proxy para um backend (/api) que pode estar fora do compose.
Ação:
Multi-stage build: Node compila → Nginx serve.
Nginx roda como usuário não-root (UID/GID fixos).
Logs vão para stdout/stderr (melhor prática de container) e, opcionalmente, também para arquivos com volume.
Variáveis de ambiente são injetadas em runtime via envsubst para gerar config do Nginx.
docker-compose.yml com read-only filesystem, tmpfs, drop de capabilities, healthcheck.
Resultado: container mais seguro, observável e fácil de configurar.
Estrutura sugerida
. ├─ Dockerfile ├─ docker-compose.yml └─ nginx/ ├─ default.conf.template └─ 50-envsubst.sh
Dockerfile (multi-stage + non-root)
FROM node:20-alpine AS build WORKDIR /app
COPY package*.json ./ RUN npm ci
COPY . . RUN npm run build
FROM nginx:1.29.4-alpine
ARG APP_UID=10101 ARG APP_GID=10101
RUN addgroup -g ${APP_GID} -S app
&& adduser -u ${APP_UID} -S -G app -H -s /sbin/nologin app
&& mkdir -p /var/cache/nginx /var/run /var/log/nginx /etc/nginx/templates
&& chown -R app:app /var/cache/nginx /var/run /var/log/nginx /etc/nginx
&& rm -rf /usr/share/nginx/html/*
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY nginx/50-envsubst.sh /docker-entrypoint.d/50-envsubst.sh
RUN chmod +x /docker-entrypoint.d/50-envsubst.sh
&& chown -R app:app /docker-entrypoint.d /etc/nginx/templates
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
USER app
CMD ["nginx", "-g", "daemon off;"]
Por que porta 8080? Para evitar capabilities/privileged port (80) e manter runtime não-root sem gambiarras.
Nginx template com env vars + SPA + proxy /api
nginx/default.conf.template
server { listen 8080; server_name _;
root /usr/share/nginx/html; index index.html;
access_log /dev/stdout; error_log /dev/stderr warn;
location = /healthz { add_header Content-Type text/plain; return 200 "ok\n"; }
location ~* .(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { expires 30d; add_header Cache-Control "public, max-age=2592000, immutable"; try_files $uri =404; }
location / { try_files $uri /index.html; }
location /api/ { 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;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
# Ex:
# BACKEND_URL=http://meu-backend.externo:8080
proxy_pass ${BACKEND_URL};
} }
Observação: proxy_pass ${BACKEND_URL}; funciona bem se você definir BACKEND_URL com o host/porta e terminar com / quando necessário.
Exemplo seguro:
BACKEND_URL=http://host:8080/ (com barra final)
Assim /api/foo vira http://host:8080/foo (se quiser manter /api, aí você ajusta o proxy_pass + rewrite).
Script para gerar conf a partir das env vars
nginx/50-envsubst.sh
#!/bin/sh set -eu
: "${BACKEND_URL:=http://localhost:8081/}"
envsubst '${BACKEND_URL}'
< /etc/nginx/templates/default.conf.template \
/etc/nginx/conf.d/default.conf
docker-compose.yml (seguro por padrão)
services: web: build: context: . dockerfile: Dockerfile container_name: react-nginx ports: - "8080:8080" environment: # backend pode ser externo (DNS/hostname/IP) # exemplos: # - http://api.meudominio.com/ # - http://10.0.0.10:9000/ BACKEND_URL: "http://api.externo.local:9000/" restart: unless-stopped
# Boas práticas hardening
read_only: true
tmpfs:
- /tmp
- /var/run
- /var/cache/nginx
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# Se você precisa de arquivos de log (geralmente não precisa):
# volumes:
# - nginx_logs:/var/log/nginx
Sobre “logs com permissão nas pastas”
Melhor prática em container: logar para stdout/stderr e coletar via Docker/compose/stack (ELK, Loki, CloudWatch etc.). Isso evita dor de cabeça com permissões e rotação.
Se sua exigência é arquivo de log, então o volume nginx_logs resolve e, como o usuário app é dono de /var/log/nginx, ele consegue escrever sem root.
React: como passar BACKEND para o frontend (quando precisa)
Se o React precisa saber o backend (ex: chamadas feitas no browser), duas abordagens comuns:
- Browser chama o mesmo host e usa /api (recomendado)
No código React, use /api como base.
Quem decide o destino real é o Nginx via BACKEND_URL.
- Injetar runtime config no React (se você não quer proxy)
Criar um env.js gerado no entrypoint com variáveis e carregar no index.html. Se você quiser, eu te passo esse padrão também.
Checklist rápido de segurança (o que já está atendido)
Runtime não-root (USER app) ✅
Porta não privilegiada (8080) ✅
read_only: true + tmpfs para paths que precisam escrita ✅
no-new-privileges ✅
cap_drop: [ALL] ✅
Logs para stdout/stderr ✅
Se você me disser:
se seu build React sai em build/ (CRA) ou dist/ (Vite),
e se você quer que /api mantenha o prefixo ou remova,
eu ajusto o proxy_pass/rewrite certinho para não quebrar rotas.
Correto. Se o build do React já é feito fora do Docker, multi-stage não é obrigatório.
Mas há implicações claras. Vou separar de forma objetiva.
Quando NÃO usar multi-stage (seu caso)
Situação
Você roda npm run build na sua máquina.
A pasta build/ (ou dist/) já existe.
A imagem só precisa servir arquivos estáticos.
Ação
Dockerfile simples, só com nginx.
Copia apenas o resultado do build.
Resultado
Build mais rápido.
Dockerfile mais simples.
Menos dependências dentro da imagem.
👉 Totalmente válido e comum em pipelines.
Dockerfile sem multi-stage (boas práticas mantidas)
FROM nginx:1.29.4-alpine
ARG APP_UID=10101 ARG APP_GID=10101
RUN addgroup -g ${APP_GID} -S app
&& adduser -u ${APP_UID} -S -G app -H -s /sbin/nologin app
&& mkdir -p /var/cache/nginx /var/run /var/log/nginx /etc/nginx/templates
&& chown -R app:app /var/cache/nginx /var/run /var/log/nginx /etc/nginx
&& rm -rf /usr/share/nginx/html/*
COPY build/ /usr/share/nginx/html/
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template COPY nginx/50-envsubst.sh /docker-entrypoint.d/50-envsubst.sh
RUN chmod +x /docker-entrypoint.d/50-envsubst.sh
&& chown -R app:app /docker-entrypoint.d /etc/nginx/templates
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
USER app
CMD ["nginx", "-g", "daemon off;"]
O que você ganha ao evitar multi-stage
✔ Imagem menor (não carrega Node) ✔ Build previsível (feito local/CI) ✔ Dockerfile mais direto ✔ Menos superfície de ataque
O que você perde
⚠ Build não é reprodutível só com docker build ⚠ Precisa garantir que build/ esteja atualizado ⚠ Dependência do ambiente da máquina ou CI
Em ambientes corporativos, isso normalmente é aceito porque o build já ocorre antes do deploy.
Regra prática (boa para equipe)
Use isso como padrão mental:
Dev local / CI já gera build → sem multi-stage
Imagem deve ser 100% autossuficiente → multi-stage
Kubernetes + GitOps → geralmente multi-stage
Legacy / pipeline simples → sem multi-stage
docker-compose.yml (não muda)
O docker-compose.yml continua idêntico ao que passei antes.
Se quiser, posso:
validar seu .gitignore para evitar subir build/ errado
adaptar isso para Vite
endurecer ainda mais (SELinux, rootfs imutável total)
Só me diga qual cenário você quer fechar como padrão.
Sim, dá para aproveitar. Só tem 2 pegadinhas que você precisa controlar: porta, paths graváveis e como o Nginx é iniciado.
- O usuário nginx já existe, mas o container pode iniciar como root
Na imagem oficial, é comum o processo master iniciar como root para abrir porta 80 e depois “dropar” para o usuário nginx nos workers (via user nginx; no nginx.conf).
Se você quer garantia de “não-root” no container, você deve:
usar porta alta (ex: 8080), e
definir USER nginx no Dockerfile (ou user: nginx no compose),
garantir que os diretórios que o Nginx precisa escrever sejam graváveis por nginx.
- O que o Nginx precisa escrever
Mesmo servindo estático, o Nginx normalmente escreve em:
/var/cache/nginx (cache/temp)
/var/run (pid)
opcionalmente /var/log/nginx (se log em arquivo)
Se você usar read_only: true no compose (boa prática), esses diretórios precisam ser tmpfs ou volume, e com permissão para o usuário nginx.
- Dockerfile usando USER nginx (sem criar usuário novo)
Exemplo bem direto (assumindo build já feito local em build/):
FROM nginx:1.29.4-alpine
RUN rm -rf /usr/share/nginx/html/* COPY build/ /usr/share/nginx/html/
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template COPY nginx/50-envsubst.sh /docker-entrypoint.d/50-envsubst.sh RUN chmod +x /docker-entrypoint.d/50-envsubst.sh
RUN mkdir -p /var/cache/nginx /var/run /var/log/nginx /etc/nginx/templates
&& chown -R nginx:nginx /var/cache/nginx /var/run /var/log/nginx /etc/nginx/templates /usr/share/nginx/html
EXPOSE 8080
USER nginx
CMD ["nginx", "-g", "daemon off;"]
✅ Isso evita o problema do “UID 10101 vs host”. ⚠ Só lembre: se você bind-mount um diretório do host para /var/log/nginx, volta a questão de permissão do host.
- docker-compose.yml seguro com USER nginx
services: web: build: . ports: - "8080:8080" environment: BACKEND_URL: "http://api.externo.local:9000/" restart: unless-stopped
# Hardening
user: "nginx"
read_only: true
tmpfs:
- /tmp
- /var/run
- /var/cache/nginx
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# Se você realmente quiser log em arquivo:
# volumes:
# - nginx_logs:/var/log/nginx
Log em arquivo vs stdout
Recomendado: access_log /dev/stdout; e error_log /dev/stderr;
Se insistir em arquivo: prefira volume nomeado (evita UID/GID do host)
- O ponto mais importante: sua config deve escutar 8080
No default.conf.template:
server { listen 8080; ... }
Se você deixar listen 80;, com USER nginx, o Nginx não sobe (porta privilegiada).
Recomendação objetiva
Sim, aproveite o usuário nginx.
Mantenha porta 8080, logs em stdout/stderr, read_only + tmpfs.
Use volume nomeado só se realmente precisar de arquivo no disco.
Se você colar aqui seu default.conf atual (ou template), eu ajusto ele para:
SPA fallback correto
proxy /api sem quebrar path
envsubst seguro (sem vazar variáveis desnecessárias)
Sim — pode ser interessante, mas depende do que você quer controlar.
Quando 1 arquivo único ajuda
Situação: você quer empacotar toda a configuração (events/http/server) em um lugar só, versionado com a app. Ação: você fornece um nginx.conf completo e não usa conf.d/default.conf. Resultado: menos “mágica” do container e menos arquivos espalhados.
Quando manter nginx.conf + conf.d/*.conf é melhor
Situação: você quer variar só o “server” (rotas, proxy, SPA) e reaproveitar defaults do Nginx. Ação: deixa nginx.conf padrão e mexe só em conf.d/default.conf. Resultado: mudanças menores e menor risco de quebrar defaults importantes.
Melhor prática que eu recomendo aqui
1 arquivo único para o “site”: conf.d/default.conf (ou template).
Deixe o nginx.conf base da imagem (a menos que você precise mudar events, worker_processes, log_format, gzip, etc.).
Isso dá equilíbrio: simples e seguro.
Se você quiser MESMO um único nginx.conf (completo)
Você consegue, e fica limpo. Exemplo pronto para SPA + proxy + não-root + logs stdout/stderr.
nginx/nginx.conf.template
user nginx; worker_processes auto;
pid /var/run/nginx.pid;
events { worker_connections 1024; }
http { include /etc/nginx/mime.types; default_type application/octet-stream;
access_log /dev/stdout; error_log /dev/stderr warn;
sendfile on; keepalive_timeout 65;
server { listen 8080; server_name _;
root /usr/share/nginx/html;
index index.html;
location = /healthz {
add_header Content-Type text/plain;
return 200 "ok\n";
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
try_files $uri =404;
}
location / {
try_files $uri /index.html;
}
location /api/ {
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;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_pass ${BACKEND_URL};
}
} }
Script nginx/50-envsubst.sh (gera o nginx.conf)
#!/bin/sh set -eu
: "${BACKEND_URL:=http://localhost:8081/}"
envsubst '${BACKEND_URL}'
< /etc/nginx/templates/nginx.conf.template \
/etc/nginx/nginx.conf
Dockerfile (usando nginx user da imagem)
FROM nginx:1.29.4-alpine
RUN rm -rf /usr/share/nginx/html/* COPY build/ /usr/share/nginx/html/
COPY nginx/nginx.conf.template /etc/nginx/templates/nginx.conf.template COPY nginx/50-envsubst.sh /docker-entrypoint.d/50-envsubst.sh RUN chmod +x /docker-entrypoint.d/50-envsubst.sh
RUN mkdir -p /var/cache/nginx /var/run /etc/nginx/templates
&& chown -R nginx:nginx /var/cache/nginx /var/run /etc/nginx /usr/share/nginx/html
EXPOSE 8080 USER nginx CMD ["nginx", "-g", "daemon off;"]
Compose (igual, com hardening)
services: web: build: . ports: - "8080:8080" environment: BACKEND_URL: "http://api.externo.local:9000/" read_only: true tmpfs: - /tmp - /var/run - /var/cache/nginx security_opt: - no-new-privileges:true cap_drop: - ALL
Pontos de atenção (para não se surpreender)
Se você usa USER nginx, sempre use porta 8080 (ou outra >1024).
Se você usar read_only: true, precisa de tmpfs para /var/run e /var/cache/nginx.
Eu recomendo stdout/stderr para logs. Só use arquivo se tiver requisito explícito.
Se você me disser se seu React é CRA (build/) ou Vite (dist/) e se o backend precisa manter o prefixo /api, eu ajusto o proxy_pass para ficar 100% correto (sem duplicar path ou cortar demais).
#!/bin/sh
set -eu
# Defaults (se você quiser manter)
APP_BASE_URL="${BASE_URL:-http://localhost:8080}"
APP_API_URL_BFF="${API_URL_BFF:-http://localhost:8080/servecel/api/v1/}"
# Monta "window.env = {...};" com todas as APP_ do ambiente
# Filtra apenas variáveis começando com APP_
# Escapa \ e " para não quebrar o JS
json_pairs=""
while IFS='=' read -r key value; do
# segurança: ignora se vier vazio
[ -n "${key:-}" ] || continue
# escapa para string JS
esc_value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
# monta lista "K":"V",
if [ -z "$json_pairs" ]; then
json_pairs=$(printf '"%s":"%s"' "$key" "$esc_value")
else
json_pairs=$(printf '%s,"%s":"%s"' "$json_pairs" "$key" "$esc_value")
fi
done <<EOF
$(env | grep '^APP_' || true)
EOF
env_config=$(printf 'window.env={%s};' "$json_pairs")
echo "Generating env-config.js with the following configuration:"
echo "$env_config"
printf '%s\n' "$env_config" | tee /usr/share/nginx/html/env-config.js >/dev/null
#!/bin/sh
set -eu
OUT_DIR="/tmp/runtime-config"
OUT_FILE="$OUT_DIR/env-config.js"
# 1) allowlist (só essas vão para o browser)
ALLOWED_VARS="
APP_BASE_URL
APP_API_URL
APP_AUTH_URL
APP_ENV
APP_VERSION
"
# 2) obrigatórias
REQUIRED_VARS="
APP_BASE_URL
APP_API_URL
"
mkdir -p "$OUT_DIR"
missing=""
for var in $REQUIRED_VARS; do
# shellcheck disable=SC2039
if [ -z "${!var:-}" ]; then
missing="$missing $var"
fi
done
if [ -n "$missing" ]; then
echo "[ERROR] Missing required environment variables:$missing" >&2
exit 1
fi
# bloqueia valores com quebra de linha (evita JS quebrado e surpresas)
validate_value() {
v="$1"
case "$v" in
*"$(
printf '\n'
)"*|*"$(
printf '\r'
)"*)
echo "[ERROR] Invalid value contains newline (blocked for safety)" >&2
exit 1
;;
esac
}
pairs=""
for key in $ALLOWED_VARS; do
val="${!key:-}"
[ -n "$val" ] || continue
validate_value "$val"
# escape básico para JS string
esc_val=$(printf '%s' "$val" | sed 's/\\/\\\\/g; s/"/\\"/g')
if [ -z "$pairs" ]; then
pairs=$(printf '"%s":"%s"' "$key" "$esc_val")
else
pairs=$(printf '%s,"%s":"%s"' "$pairs" "$key" "$esc_val")
fi
done
printf 'window.env={%s};\n' "$pairs" > "$OUT_FILE"
chmod 0444 "$OUT_FILE"
echo "[app-setup] Generated $OUT_FILE (allowlist + required OK)"server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
# env-config.js gerado em runtime
location = /env-config.js {
alias /tmp/runtime-config/env-config.js;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
add_header Content-Type "application/javascript; charset=utf-8" always;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Headers básicos (ajuste conforme sua política)
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Frame-Options "DENY" always;
}services:
web:
image: sua-imagem:tag
ports:
- "8080:8080"
environment:
APP_BASE_URL: "https://frontend.exemplo.com"
APP_API_URL: "https://api.exemplo.com"
APP_ENV: "prod"
APP_VERSION: "1.2.3"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev
- /tmp/runtime-config:rw,noexec,nosuid,nodev
- /var/cache/nginx:rw,noexec,nosuid,nodev
- /var/run:rw,noexec,nosuid,nodev
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# opcional: limita recursos (bom em auditoria)
# deploy:
# resources:
# limits:
# cpus: "0.50"
# memory: "256M"#!/bin/sh
set -eu
OUT_DIR="/tmp/runtime-config"
OUT_FILE="$OUT_DIR/runtime-config.json"
ALLOWED_VARS="
APP_BASE_URL
APP_API_URL
APP_AUTH_URL
APP_ENV
APP_VERSION
"
REQUIRED_VARS="
APP_BASE_URL
APP_API_URL
"
mkdir -p "$OUT_DIR"
missing=""
for var in $REQUIRED_VARS; do
if [ -z "${!var:-}" ]; then
missing="$missing $var"
fi
done
if [ -n "$missing" ]; then
echo "[ERROR] Missing required environment variables:$missing" >&2
exit 1
fi
validate_value() {
v="$1"
case "$v" in
*"$(
printf '\n'
)"*|*"$(
printf '\r'
)"*)
echo "[ERROR] Invalid value contains newline (blocked for safety)" >&2
exit 1
;;
esac
}
json=""
for key in $ALLOWED_VARS; do
val="${!key:-}"
[ -n "$val" ] || continue
validate_value "$val"
# JSON string escape mínimo: \ e "
esc_val=$(printf '%s' "$val" | sed 's/\\/\\\\/g; s/"/\\"/g')
if [ -z "$json" ]; then
json=$(printf '"%s":"%s"' "$key" "$esc_val")
else
json=$(printf '%s,"%s":"%s"' "$json" "$key" "$esc_val")
fi
done
printf '{%s}\n' "$json" > "$OUT_FILE"
chmod 0444 "$OUT_FILE"
echo "[app-setup] Generated $OUT_FILE"server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
location = /runtime-config.json {
alias /tmp/runtime-config/runtime-config.json;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
default_type application/json;
add_header X-Content-Type-Options "nosniff" always;
}
location / {
try_files $uri $uri/ /index.html;
}
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Frame-Options "DENY" always;
}async function loadRuntimeConfig() {
const res = await fetch("/runtime-config.json", { cache: "no-store" });
if (!res.ok) throw new Error("Failed to load runtime-config.json");
return res.json();
}
loadRuntimeConfig()
.then((cfg) => {
(window as any).__RUNTIME_CONFIG__ = cfg;
// render app
})
.catch((err) => {
// opcional: render uma tela de erro “Config inválida”
console.error(err);
});#!/bin/sh
set -eu
# Gera env-config.js em tmpfs (compatível com read-only rootfs)
# Serve via nginx (recomendado: location = /env-config.js { alias /tmp/runtime-config/env-config.js; ... })
OUT_DIR="/tmp/runtime-config"
OUT_FILE="$OUT_DIR/env-config.js"
# Variáveis obrigatórias (somente estas 2 entram no arquivo)
: "${APP_BASE_URL:?Missing required environment variable APP_BASE_URL}"
: "${APP_API_URL:?Missing required environment variable APP_API_URL}"
mkdir -p "$OUT_DIR"
# Normaliza CRLF (evita ^M do Windows)
APP_BASE_URL=$(printf '%s' "$APP_BASE_URL" | sed 's/\r$//')
APP_API_URL=$(printf '%s' "$APP_API_URL" | sed 's/\r$//')
# Bloqueia newline real por segurança (evita JS quebrado / comportamento inesperado)
case "$APP_BASE_URL" in
*"$(
printf '\n'
)"*) echo "[ERROR] APP_BASE_URL contains newline (blocked for safety)" >&2; exit 1 ;;
esac
case "$APP_API_URL" in
*"$(
printf '\n'
)"*) echo "[ERROR] APP_API_URL contains newline (blocked for safety)" >&2; exit 1 ;;
esac
# Escape mínimo para JS string
esc() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
BASE_ESC=$(esc "$APP_BASE_URL")
API_ESC=$(esc "$APP_API_URL")
cat <<EOF > "$OUT_FILE"
window.env={
APP_BASE_URL: "$BASE_ESC",
APP_API_URL: "$API_ESC"
};
EOF
chmod 0444 "$OUT_FILE"
echo "[app-setup] Generated $OUT_FILE"read_only: true
tmpfs:
- /tmp/runtime-config:rw,noexec,nosuid,nodev
- /var/cache/nginx:rw,noexec,nosuid,nodev
- /var/run:rw,noexec,nosuid,nodev
- /tmp:rw,noexec,nosuid,nodevJson
#!/bin/sh
set -eu
OUT_DIR="/tmp/runtime-config"
OUT_FILE="$OUT_DIR/runtime-config.json"
: "${APP_BASE_URL:?Missing required environment variable APP_BASE_URL}"
: "${APP_API_URL:?Missing required environment variable APP_API_URL}"
mkdir -p "$OUT_DIR"
# Normaliza CRLF no final (caso venha de Windows)
trim_eol() { printf '%s' "$1" | sed 's/[\r\n]*$//'; }
APP_BASE_URL="$(trim_eol "$APP_BASE_URL")"
APP_API_URL="$(trim_eol "$APP_API_URL")"
# Bloqueia newline dentro do valor (isso é config inválida)
case "$APP_BASE_URL" in *"$(
printf '\n'
)"*|*"$(
printf '\r'
)"*) echo "[ERROR] APP_BASE_URL contains newline (blocked)" >&2; exit 1 ;; esac
case "$APP_API_URL" in *"$(
printf '\n'
)"*|*"$(
printf '\r'
)"*) echo "[ERROR] APP_API_URL contains newline (blocked)" >&2; exit 1 ;; esac
# Escape mínimo para JSON string
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
BASE_ESC="$(json_escape "$APP_BASE_URL")"
API_ESC="$(json_escape "$APP_API_URL")"
cat <<EOF > "$OUT_FILE"
{
"APP_BASE_URL": "$BASE_ESC",
"APP_API_URL": "$API_ESC"
}
EOF
chmod 0444 "$OUT_FILE"
echo "[runtime-config] Generated $OUT_FILE"location = /runtime-config.json {
alias /tmp/runtime-config/runtime-config.json;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
default_type application/json;
add_header X-Content-Type-Options "nosniff" always;
}Beleza. Vamos fechar isso de forma definitiva usando JSON (mais seguro e mais limpo que injetar JS), mantendo:
runtime (sem rebuild)
non-root
logs em arquivo no host (como você decidiu)
sem read_only (como você decidiu)
runtime-config.json gerado em /tmp/runtime-config
A solução fica estável e fácil de auditar.
- Script em /docker-entrypoint.d/90-runtime-config.sh
Gera somente 2 variáveis (rígido e simples), falha se faltar.
#!/bin/sh set -eu
OUT_DIR="/tmp/runtime-config" OUT_FILE="$OUT_DIR/runtime-config.json"
: "${APP_BASE_URL:?Missing required environment variable APP_BASE_URL}" : "${APP_API_URL:?Missing required environment variable APP_API_URL}"
mkdir -p "$OUT_DIR"
trim_eol() { printf '%s' "$1" | sed 's/[\r\n]*$//'; }
APP_BASE_URL="$(trim_eol "$APP_BASE_URL")" APP_API_URL="$(trim_eol "$APP_API_URL")"
case "$APP_BASE_URL" in "$( printf '\n' )"|"$( printf '\r' )") echo "[ERROR] APP_BASE_URL contains newline (blocked)" >&2; exit 1 ;; esac
case "$APP_API_URL" in "$( printf '\n' )"|"$( printf '\r' )") echo "[ERROR] APP_API_URL contains newline (blocked)" >&2; exit 1 ;; esac
json_escape() { printf '%s' "$1" | sed 's/\/\\/g; s/"/\"/g' }
BASE_ESC="$(json_escape "$APP_BASE_URL")" API_ESC="$(json_escape "$APP_API_URL")"
cat < "$OUT_FILE" { "APP_BASE_URL": "$BASE_ESC", "APP_API_URL": "$API_ESC" } EOF
chmod 0444 "$OUT_FILE" echo "[runtime-config] Generated $OUT_FILE"
Coloque esse arquivo no seu repo e copie no Dockerfile para /docker-entrypoint.d/90-runtime-config.sh.
- Nginx: servir o JSON (com no-cache) via alias
No seu server { ... }:
location = /runtime-config.json { alias /tmp/runtime-config/runtime-config.json;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always; add_header Pragma "no-cache" always; add_header Expires "0" always;
default_type application/json; add_header X-Content-Type-Options "nosniff" always; }
E o resto da SPA:
location / { try_files $uri $uri/ /index.html; }
- Dockerfile: copiar script e manter non-root + logs
Exemplo (ajuste paths conforme seu repo):
FROM nginx:1.29.4-alpine
COPY --chown=nginx:nginx nginx/nginx.conf /etc/nginx/nginx.conf COPY --chown=nginx:nginx nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --chown=nginx:nginx --chmod=0755 nginx/90-runtime-config.sh /docker-entrypoint.d/90-runtime-config.sh
COPY --chown=nginx:nginx build/ /usr/share/nginx/html/
RUN mkdir -p /var/cache/nginx /var/log/nginx /tmp/runtime-config
&& chown -R nginx:nginx /var/cache/nginx /var/log/nginx /tmp
USER nginx EXPOSE 8080
- docker-compose.yml: env + logs no host
services: web: image: sua-imagem container_name: servcelweb-vivo-pre-front restart: always
ports:
- "8080:8080"
environment:
APP_BASE_URL: "http://localhost:8080"
APP_API_URL: "http://selcdspgw002.paulista.local:8083/servcel/api/v1"
volumes:
- ./logs/nginx:/var/log/nginx
Atenção: crie a pasta antes:
mkdir -p ./logs/nginx
Se der permissão, aplique (rápido):
chmod 777 ./logs/nginx
Ou (mais correto), alinhe UID/GID do nginx e chown no host.
- React: carregar JSON antes de renderizar (definitivo)
src/runtime-config.ts
export type RuntimeConfig = { APP_BASE_URL: string; APP_API_URL: string; };
declare global { interface Window { RUNTIME_CONFIG?: RuntimeConfig; } }
export async function loadRuntimeConfig(): Promise {
const res = await fetch("/runtime-config.json", { cache: "no-store" });
if (!res.ok) {
throw new Error(Failed to load runtime-config.json (${res.status}));
}
return (await res.json()) as RuntimeConfig;
}
src/main.tsx (ou index.tsx)
Carregue antes do createRoot().render(...):
import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import { loadRuntimeConfig } from "./runtime-config";
async function bootstrap() { const cfg = await loadRuntimeConfig(); window.RUNTIME_CONFIG = cfg;
ReactDOM.createRoot(document.getElementById("root")!).render( <React.StrictMode> </React.StrictMode> ); }
bootstrap().catch((err) => { // aqui você pode renderizar uma tela simples de erro de config console.error(err); });
Uso no app
const apiUrl = window.RUNTIME_CONFIG?.APP_API_URL;
- Teste final (sem dúvida)
Suba:
docker compose down --remove-orphans docker compose up --build
Teste:
curl -i http://localhost:8080/runtime-config.json
Você deve ver:
200 OK
Cache-Control: no-store...
JSON com as duas chaves
Por que isso resolve “de forma definitiva”
JSON não executa código.
Você não depende de <script src="...">.
O arquivo é gerado no start (runtime).
O Nginx serve via alias direto do /tmp.
O React carrega uma vez e pronto.
Se você quiser, eu também ajusto seu nginx.conf completo para:
manter logs em arquivo (host)
e evitar qualquer conflito com try_files que faça o JSON cair no index.html.
SCRIPT_PATH="$($READLINK -f "$0" 2>/dev/null || python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$0")"
ROOT="$(dirname "$SCRIPT_PATH")"
SCRIPT_PATH="$($READLINK -f "$0")"
ROOT="$(dirname "$SCRIPT_PATH")"
git submodule
- 4c28c9621d6d2f60638bf802deba3fb6
gerenciamento memoria em C
- 30902f0a293ef58f4017d882220a72b6