Most of my projects are small enough that a build pipeline would be more moving parts than the project itself. One of them is a static operations dashboard served by nginx from a single directory, and that directory is also the git checkout. There is no release step and no copy into place, which changes what the word deploy means.

The document root is the worktree

nginx reads the site from the checkout itself:

server_name agentjj.com.br; root /var/www/central_operacoes; 

That path is a working tree with a branch, a remote and 645 commits of history, and nginx serves whatever is on disk at the moment of the request. I edit a file, refresh the page, and the change is live for whoever has the password. The deploy script does not move files into place, because they are already there. It proves that what is on disk behaves, and then records it in git.

CSS, JavaScript and JSON come back with no-cache, images with max-age=31536000 and immutable, and the HTML references its assets as ?v=<short sha>, rewritten on every commit. An immutable image URL is only safe when a changed image gets a new URL.

Steps one and four are the point

The script prints five steps in order. The commit comes first and the push comes last:

echo "==> 1/5 commit" git add -A # step 2/5 rewrites the asset tag in index.html echo "==> 3/5 verificacao local" if [[ "$fail" != "0" ]]; then echo "==> verificacao local FALHOU - push cancelado" >&2 exit 1 fi echo "==> 4/5 push" 

Read the failure line again. It says push cancelled, not change cancelled. By the time the smoke test runs, the commit exists and the directory has been serving it for the whole duration of the test. A failed verification leaves two states at once: the site is on the new version and origin/master is on the old one. They cost different things, so the useful move after a failure is to measure the distance instead of deciding whether the work was lost.

git rev-list --count origin/master..HEAD # commits not pushed git status --porcelain # is the tree clean? 

A number above zero means the site is serving something GitHub has never seen, one command away from fine, but only if you know which state you are in. The order still earns its place: verifying before push keeps a broken change off the remote. It never protected the server, since the server is the checkout.

The check that quietly skipped itself

For a while the verification block had a reasonable-looking branch: when no credential was available, skip the authenticated requests and continue. The script then printed a green result having made zero authenticated requests. The commit that removed that branch carries the reason in its message:

2ce1b72 feat: exigir credencial no deploy (o SKIP silencioso ja deixou passar deploy sem nenhum teste autenticado) 

The replacement refuses to run without a credential and requires an intentional skip to be typed as an input:

if [[ -z "${SITE_AUTH:-}" && -z "${SITE_USER:-}" ]]; then if [[ "${ALLOW_SKIP_VERIFY:-0}" == "1" ]]; then echo " AVISO: verificacao autenticada PULADA (ALLOW_SKIP_VERIFY=1)." >&2 else echo " FAIL sem credencial para a verificacao autenticada." >&2 exit 1 fi fi 

A gate that can disable itself without saying so is worse than no gate, because it produces exactly the output of a passing one. Whatever can be skipped needs an explicit request, and the skip has to appear in the output where a tired reader will see it.

What the smoke test asserts besides 200

Each check compares a status code against an expected one, hitting the origin directly so DNS and the CDN stay out of the result:

check() { local url="$1" expect="$2" desc="$3" code="$(auth_curl -o /dev/null -w '%{http_code}' \ --resolve "agentjj.com.br:443:127.0.0.1" "${BASE}${url}" || echo 000)" if [[ "$code" != "$expect" ]]; then echo " FAIL ${desc} esperado=${expect} obtido=${code}" >&2 fail=1 fi } check "/" "200" "index" check "/css/style.css?v=${ASSET_SHA}" "200" "css" 

Three of the assertions are not about 200, and those catch the real breakage:

  • an anonymous request has to answer 401, otherwise basic auth is protecting nothing;
  • /favicon.ico and /robots.txt have to answer 200 without credentials, because the browser fetches both on its own and a 401 there reopens the login prompt after the page already loaded;
  • the served HTML must not contain a __PLACEHOLDER__ or a literal $(git ...), both render as text in production.

Port 80 taught one more. It used to redirect to HTTPS with a 301, and the browser drops the Authorization header when the scheme changes, so a request that arrived with the password left without it and the user landed on a 401 page in a loop. The commit that changed it says what happened:

39c527e fix: serve site on port 80 instead of 301 (auth header was dropped on http->https) 

Now port 80 serves the site and enforces the same credential. Measured on the running server today: anonymous returns 401, credentialed returns 200, and /favicon.ico returns 200 without either.

The cache header that identified the wrong password

The last check compares Cache-Control on two responses, and the diagnosis lives in the comparison:

if grep -q 'no-store' <<<"${html_cc:-}" && grep -q 'no-cache' <<<"${css_cc:-}"; then echo " OK politica de cache" else if [[ "$html_cc" == "no-store" && "$css_cc" == "no-store" ]]; then echo " FAIL ambos os assets responderam 401.html (no-store) em vez do conteudo." >&2 echo " Quase sempre e SENHA ERRADA em ~/.site_auth, nao cache." >&2 else echo " FAIL politica de cache inesperada" >&2 fi fail=1 fi 

The HTML should come back with no-cache, no-store, must-revalidate, max-age=0 and the CSS with no-cache. The error page for 401 is served with no-store alone. So when both responses carry exactly no-store, the requests were not served the site, they were served the login error. The first version of this check printed cache policy unexpected and sent me into the nginx configuration for a problem that was a mistyped password. Measured today on the live server, that is exactly the pair I get.

git add -A commits whatever happens to be dirty

Because the root is a worktree, leftover output from an earlier session sits next to the source. The first instruction of the script stages everything:

git add -A if git diff --cached --quiet; then echo " nada a commitar - tag de versao preservada" else git commit -q -m "$MSG" fi 

A build artifact or an experiment left behind by a previous run becomes part of the commit without an extra keystroke. The pre-flight has to happen before the script runs, not inside it:

git status --short 

If anything in that output is not yours, decide about it before staging, because afterwards the commit is one block and the two origins are no longer separable. The repository also needs a .gitignore covering interpreter cache and the credential file before the first add -A, or bytecode and a password end up in the history. Mine ignores __pycache__/, *.py[cod] and .site_auth, and the deploy reads the credential from ~/.site_auth with mode 600 outside the repository, refusing to continue if the mode is anything else.

What I would carry to the next project

Serving production from the checkout removes the gap between what I tested and what the user opens. The discipline is short:

  1. Keep the credential out of the tree and let the script refuse to run without it.
  2. Make every skip an explicit input, and print what was not tested.
  3. Assert the negative case, an anonymous 401, not only the positive one.
  4. Compare pairs of headers instead of trusting one value.
  5. After a failed deploy, measure the distance to origin before saying anything was lost.
  6. Check git status --short before staging everything.

None of that is specific to nginx or bash. It comes from one property: when the thing serving the files is the thing you edit, the filesystem is the moment of truth.

Se você mantém um projeto pequeno em produção e passa por algo parecido, comenta aí como resolveu o seu caso. Sempre aparece uma peça que eu não tinha considerado, e é disso que este texto se alimenta.