Deploy, při kterém spadne pár requestů, nikoho nezabije — ale nedůvěra se nasčítá. Uživatel dostane 502, formulář se neodešle, monitoring pípne. A když se něco pokazí, chcete být zpátky na staré verzi hned — ne pět minut cvičit s Gitem na produkci a modlit se. Dobrá zpráva: nasazení bez výpadku není raketová věda ani výsada týmů s Kubernetes. Stačí tři osvědčené kusy a jedna myšlenka. V tomhle článku složíme sestavu, která běžný web nasadí bez jediného zahozeného spojení: Nginx jako reverse proxy, HTTPS přes Let's Encrypt s automatickou obnovou a nasazování přepnutím jediného symlinku. Rollback? Jeden příkaz a jste zpátky.

Všechny příkazy platí pro Debian 12 a Ubuntu 22.04+. Předpokládám, že máte doménu směrovanou na server a pracujete jako uživatel s sudo.

Adresářová struktura, ze které to celé vychází

Základ atomického nasazení je oddělit kód konkrétní verze od toho, co je právě naostro. K tomu se osvědčila tři místa:

  • releases/ — každá verze ve vlastním, časově pojmenovaném adresáři
  • shared/ — věci, které přežívají napříč verzemi: .env, nahrané soubory, logy
  • current — symlink, který ukazuje na právě aktivní release
sudo mkdir -p /var/www/app/{releases,shared}

Aplikace i Nginx pak sahají výhradně na /var/www/app/current. Nikdy neukazují přímo do releases/. Celý deploy se scvrkne na jedinou operaci: přepnout, kam current míří.

Nginx jako reverse proxy

Aplikace (Node, PHP-FPM, Python, cokoli) běží na localhostu, třeba na portu 3000. Nginx stojí před ní, ukončuje HTTPS a předává provoz dál. Konfiguraci dejte do /etc/nginx/sites-available/:

sudo apt update && sudo apt install nginx
server {
    listen 80;
    server_name example.com www.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;

        # když aplikace používá WebSockety
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Ty čtyři hlavičky nejsou kosmetika. Bez X-Forwarded-Proto aplikace netuší, že přišla přes HTTPS, a klidně vám udělá redirect smyčku. Bez X-Forwarded-For uvidíte v logu pořád jen IP Nginxu.

Aktivujte site symlinkem a nasypte to naostro:

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

nginx -t je návyk, který vám jednou zachrání večer — otestuje konfiguraci dřív, než ji nasadíte. A reload (na rozdíl od restart) je řízený: Nginx spustí nové workery, staré nechá dokončit rozdělané requesty a teprve pak je ukončí. Žádné zahozené spojení.

HTTPS přes Let's Encrypt

Na Debianu a Ubuntu je nejpřímější cesta certbot z balíčků i s pluginem pro Nginx:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

Certbot si vyžádá certifikát a sám dopíše do konfigurace listen 443 ssl, cesty k certifikátům i include s doporučenými SSL parametry. Jednu věc ale za vás automaticky neudělá: přesměrování z HTTP na HTTPS. V interaktivním režimu se na něj zeptá dotazem, jestli chcete provoz přesměrovat — vyberte volbu Redirect. Když certbot spouštíte bezobslužně, dopřejte mu přepínač --redirect (nebo --no-redirect, pokud ho výslovně nechcete). Bez toho zůstane HTTP i HTTPS vedle sebe.

Certifikáty od Let's Encryptu platí 90 dní, takže obnova je nutnost, ne detail.

Automatická obnova

Tady je dobrá zpráva: balíček certbotu automatickou obnovu nastaví za vás. Nainstaluje systemd timer, který dvakrát denně spustí certbot renew. Ten se ale nesnaží obnovovat pořád — sáhne jen na certifikáty, kterým zbývá méně než třetina životnosti. U standardního 90denního certifikátu od Let's Encryptu to vychází zhruba na 30 dní před expirací (starší verze certbotu měly tuhle hranici napevno na 30 dnech, novější počítají třetinu životnosti, takže u kratších certifikátů je práh nižší). Aktuální certbot navíc umí ctít ARI a obnovit i dřív, pokud to certifikační autorita doporučí. Prakticky z toho plyne jediné: timer klidně nechte běžet dvakrát denně, obnova se spustí sama ve správnou chvíli. Ověřte, že timer opravdu jede:

systemctl list-timers | grep certbot

A vyzkoušejte, že obnova reálně proběhne, nanečisto:

sudo certbot renew --dry-run

Když jste certifikát vydali s --nginx, certbot po obnově Nginx sám reloadne. Pokud používáte jiný způsob ověření (webroot, standalone) nebo potřebujete po obnově nakopnout ještě vlastní službu, dejte skript do adresáře deploy hooků — spustí se po každé úspěšné obnově:

# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/usr/bin/env bash
systemctl reload nginx

Certifikát, který se neobnoví sám, je jen odložený výpadek s datem splatnosti.

Atomické nasazení přes symlinky

Teď to hlavní. Novou verzi nepřepisujeme na místě — vždycky ji celou nachystáme stranou, a teprve když je hotová, přepneme na ni current.

Proč zrovna symlink? Protože jeho přepnutí jde udělat atomicky. Systémové volání rename() buď proběhne celé, nebo vůbec — nikdy neexistuje okamžik, kdy by current mířil „napůl". Jenže pozor: samotné ln -sfn atomické není. Interně nejdřív starý symlink smaže a pak vytvoří nový, a mezi tím je štěrbina, do které se vám může trefit request. Trik je vytvořit dočasný symlink a přejmenovat ho přes mv -T, které pod kapotou volá právě rename():

ln -sfn "$NEW_RELEASE" /var/www/app/current_tmp
mv -Tf /var/www/app/current_tmp /var/www/app/current

-T (--no-target-directory) říká „ber cíl vždycky jako obyčejný soubor, ne jako adresář", takže se nový symlink nevloží dovnitř toho, na co current právě ukazuje. Nepleťte si ho s -n (--no-dereference) — ten dělá něco slabšího a při současném zadání ho -T stejně přebije. Tohle -T je celé kouzlo za slovem „atomické".

Deploy skript

Poskládané dohromady vypadá nasazení takhle. Konkrétní build (npm, composer, cokoli) si dosaďte podle svého stacku:

#!/usr/bin/env bash
set -euo pipefail

APP=/var/www/app
NEW_RELEASE="$APP/releases/$(date +%Y%m%d%H%M%S)"

# 1. nachystat novou verzi stranou
mkdir -p "$NEW_RELEASE"
git clone --depth 1 [email protected]:app.git "$NEW_RELEASE"
# ... build, instalace závislostí ...

# 2. napojit sdílené soubory
ln -sfn "$APP/shared/.env"     "$NEW_RELEASE/.env"
ln -sfn "$APP/shared/storage"  "$NEW_RELEASE/storage"

# 3. atomicky přepnout current
ln -sfn "$NEW_RELEASE" "$APP/current_tmp"
mv -Tf "$APP/current_tmp" "$APP/current"

# 4. řízený reload aplikace a Nginxu
sudo systemctl reload app.service
sudo nginx -t && sudo systemctl reload nginx

set -euo pipefail na začátku je dobrý zvyk: skript se zastaví, jakmile něco selže, a nezačne slepě pokračovat dál. Nedělejte si z něj ale železnou záruku. set -e má svoje slepá místa — neaktivuje se uvnitř if, ve while, v řetězci &&/||, po !, a třeba local x=$(...) návratový kód spolkne. A hlavně: nic nevrací zpět. Když spadne pozdější krok, adresáře a soubory z těch dřívějších tam prostě zůstanou. Poloviční nasazení tedy neeliminuje set -euo pipefail, ale ten princip pod ním: build běží stranou v samostatném adresáři a naostro se dostane až jediným atomickým přepnutím symlinku. set -euo pipefail tomu jen pomáhá tím, že běh přeruší dřív, než se k přepnutí vůbec dostane — dokud mv neproběhne, jede naostro pořád stará verze.

Poznámka na rovinu: nulový výpadek nakonec stojí a padá s tím, jak umí graceful přechod vaše aplikace. Nginx svou část odbaví bez díry, ale samotnou appku musíte přepnout tak, aby dobojovala rozdělané requesty. Možností je několik:

  • pm2 reload — bez výpadku, ale jen v cluster módu, kdy pm2 vyměňuje workery po jednom (rolling restart). Ve fork módu z toho je obyčejný stop/start s dírou.
  • systemctl reload u služby, která má definovaný ExecReload — typicky pošle procesu SIGHUP a ten si znovu načte konfiguraci za běhu.
  • socket aktivace v systemd — tady je bez výpadku i tvrdý restart: naslouchající socket drží systemd, takže nová spojení čekají v kernelové frontě, než nový proces naběhne.
  • reload php-fpm (SIGUSR2) — plynulá výměna workerů.

Tvrdý restart bez socket handoveru uprostřed provozu naopak tu vteřinovou díru udělá.

Rychlý rollback

Protože stará verze pořád leží v releases/, návrat zpátky je jen přepnutí symlinku na předchozí adresář:

PREV=$(ls -1dt /var/www/app/releases/*/ | sed -n '2p')
ln -sfn "$PREV" /var/www/app/current_tmp
mv -Tf /var/www/app/current_tmp /var/www/app/current
sudo systemctl reload app.service

ls -1dt seřadí releasy od nejnovějšího, sed -n '2p' vezme druhý v pořadí — tedy tu verzi, co jela před posledním deployem. Žádný build, žádný Git, žádná panika. Vteřina.

Úklid starých releasů

Aby vám releases/ nesnědl disk, na konci deploye smažte všechno kromě posledních pěti verzí:

ls -1dt /var/www/app/releases/*/ | tail -n +6 | xargs -r rm -rf

tail -n +6 propustí řádky od šestého dál, xargs -r nic nespustí, když seznam náhodou vyjde prázdný. Pět verzí je rozumný kompromis — dost na to, abyste se měli kam vrátit, ne tolik, aby to bobtnalo.

Take-away

  • Reverse proxy v Nginxu odděluje TLS a routing od aplikace; reload je řízený a spojení nezahazuje.
  • Certbot z apt vyřídí HTTPS i automatickou obnovu; přesměrování HTTP→HTTPS si ale vyžádá volbu Redirect nebo přepínač --redirect. certbot renew --dry-run je jediný test, který vás od výpadku dělí.
  • Atomický symlink (ln -sfn do dočasného + mv -T) přepne verzi bez štěrbiny. Samotné ln -sfn atomické není.
  • Rollback je jen přepnutí symlinku zpátky — proto starou verzi nemažte hned.

Celé to drží na jedné myšlence: nová verze se nikdy nedotkne té běžící, dokud není stoprocentně hotová. A když se něco pokazí, cesta zpátky vede stejnou branou, kterou jste přišli — jedním přepnutím symlinku.