SSD Nodes Learn Hosting plans →
Przewodniki Matt ConnorAutor: Matt Connor

Deploy przez git push na VPS: bare repo i post-receive

Deploy strony jednym git push z laptopa: użytkownik deploy, repozytorium bare, hook post-receive i checkout do katalogu WWW. Plus dwa błędy uprawnień, które psują pierwszy push.

Co daje deploy przez git push na VPS

Deploy przez git push na VPS to trzy elementy: repozytorium bare na serwerze, hook post-receive, który po każdym pushu wypakowuje pliki do katalogu strony, i zwykłe git push z laptopa. Nie potrzebujesz do tego żadnej funkcji od dostawcy ani zewnętrznego serwisu. Całość to jeden użytkownik systemowy, jedno polecenie git init --bare i skrypt na kilkanaście linii.

Ten mechanizm jest wbudowany w samego gita. Każdy push, także ten na GitHuba, kończy się po stronie serwera uruchomieniem programu git-receive-pack. Zapisuje on nowe obiekty, aktualizuje gałęzie i na końcu woła hooki z katalogu hooks/ repozytorium. Na własnym serwerze ten katalog należy do ciebie, więc to ty decydujesz, co ma się stać z nową wersją kodu.

Co naprawdę znaczy „hosting z git” na hostingu współdzielonym

W ofertach polskich hostingów współdzielonych „hosting z git” albo „obsługa Git” znaczy zwykle dwie rzeczy: masz dostęp przez SSH i na serwerze jest zainstalowany program git. Nic więcej. Logujesz się, wchodzisz do katalogu public_html, robisz git clone z GitHuba albo GitLaba i przy każdej zmianie wracasz na serwer, żeby zrobić git pull. To jest git jako narzędzie do kopiowania plików, w miejsce FTP. Samo wdrożenie nadal uruchamiasz ręcznie, po stronie serwera.

Zanim zapłacisz za taki pakiet, sprawdź cztery rzeczy. Czy dostęp SSH jest w twoim planie, a nie tylko w droższym. Czy po zalogowaniu git --version w ogóle działa, bo bywa, że git jest tylko w panelu, a nie w powłoce. Czy możesz dodać własny klucz SSH, zamiast logować się hasłem. I czy panel pozwala zablokować publiczny dostęp do katalogu .git, bo po sklonowaniu repozytorium do public_html cała historia projektu, razem z każdym plikiem, który kiedykolwiek do niej trafił, leży pod adresem https://twoja-domena.pl/.git/.

Na VPS nie musisz szukać tego w ofercie. Masz roota, więc masz SSH i gita, a każdy katalog jest twój. Możesz więc zbudować mocniejszą wersję: push z laptopa, który sam wypakowuje pliki, bez logowania na serwer i bez katalogu .git w katalogu strony. Jeśli dopiero stawiasz serwer pod stronę, zacznij od postawienia własnej strony na VPS i wróć tutaj, kiedy nginx już serwuje pliki.

Jak działa push na serwer: receive-pack, hook i katalog roboczy

Trzy pojęcia, których potrzebujesz. Repozytorium bare to repozytorium bez katalogu roboczego: sam katalog .git, tylko bez kropki w nazwie i bez plików projektu obok. Na serwerze chcesz właśnie takiego, bo nikt nie będzie tam edytował kodu, a push do gałęzi, która jest akurat wyciągnięta w zwykłym repozytorium, git odrzuca. Hook to skrypt w katalogu hooks/ repozytorium, który git uruchamia w określonym momencie. post-receive startuje raz po każdym pushu, kiedy gałęzie są już zaktualizowane. Katalog roboczy (work tree) to miejsce, gdzie git wypakowuje pliki z commita. Normalnie leży obok .git, ale flaga --work-tree pozwala wskazać dowolny katalog, na przykład ten, z którego nginx serwuje stronę.

Plan jest więc taki: /srv/site.git to repozytorium bare, /srv/www/site to katalog roboczy i jednocześnie root w nginx, a hook łączy jedno z drugim poleceniem git --work-tree=/srv/www/site --git-dir=/srv/site.git checkout -f. Wszystko należy do osobnego użytkownika deploy.

Krok 1: użytkownik deploy bez sudo

Push przez SSH loguje się na serwer jako jakiś użytkownik i to on uruchamia git-receive-pack i hook. Nie rób tego jako root ani jako twoje konto administracyjne. Osobny użytkownik deploy ma prawa tylko do dwóch katalogów, więc klucz SSH, który trzymasz na laptopie i być może w CI, nie daje nikomu roota. To ten sam powód, dla którego każda usługa na VPS dostaje własne konto z minimalnymi uprawnieniami.

sudo adduser --disabled-password --gecos "" deploy
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
echo 'ssh-ed25519 AAAA...twoj-klucz-publiczny... laptop' | sudo tee /home/deploy/.ssh/authorized_keys
sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys

--disabled-password oznacza, że na to konto nie da się zalogować hasłem, tylko kluczem. Wklej zawartość swojego ~/.ssh/id_ed25519.pub z laptopa w miejsce ssh-ed25519 AAAA.... Użytkownik deploy nie jest w grupie sudo i ma tak zostać.

Krok 2: repozytorium bare i katalog strony

Najpierw sam git, bo obrazy Ubuntu 24.04 nie zawsze mają go zainstalowanego:

sudo apt update && sudo apt install -y git
git --version

Potem oba katalogi i puste repozytorium:

sudo install -d -o deploy -g deploy /srv/site.git
sudo install -d -m 755 -o deploy -g deploy /srv/www/site
sudo -u deploy git init --bare -b main /srv/site.git
ls -ld /srv/site.git /srv/www/site

Oba katalogi tworzy root, ale właścicielem od razu jest deploy, bo install -o deploy -g deploy ustawia właściciela w tym samym kroku. git init uruchamiasz już jako deploy, przez sudo -u deploy. Ta kolejność ma znaczenie. Od wersji 2.35.2 git odmawia pracy w repozytorium, którego właścicielem jest inny użytkownik niż ten, który wywołuje polecenie. Gdybyś zrobił sudo git init --bare, repozytorium należałoby do roota, a pierwszy push jako deploy zakończyłby się błędem fatal: detected dubious ownership in repository at '/srv/site.git'.

Flaga -b main ustawia, na którą gałąź wskazuje HEAD w nowym repozytorium. Bez niej git użyje ustawienia init.defaultBranch, a jeśli go nie ma, gałęzi master. Hook poniżej wdraża tylko gałąź, na którą wskazuje HEAD repozytorium, więc ta nazwa musi się zgadzać z tym, co wypychasz z laptopa. Dlatego lokalnie też używaj git init -b main, a jeśli stary projekt ma master, zamień -b main na -b master w poleceniu wyżej. ls -ld powinien pokazać oba katalogi z właścicielem deploy deploy.

Krok 3: hook post-receive, który czyta oldrev, newrev i refname

Git nie przekazuje hookowi argumentów. Zamiast tego na standardowe wejście post-receive trafia jedna linia na każdą wypchniętą gałąź: stary commit, nowy commit i pełna nazwa referencji, na przykład refs/heads/main. Hook czyta te linie w pętli i nie ma w nim wpisanej na sztywno nazwy gałęzi.

sudo -u deploy tee /srv/site.git/hooks/post-receive > /dev/null <<'EOF'
#!/bin/sh
set -eu

REPO=/srv/site.git
WORK_TREE=/srv/www/site
DEPLOY_REF=$(git --git-dir="$REPO" symbolic-ref HEAD)

while read -r oldrev newrev refname; do
  if [ "$refname" != "$DEPLOY_REF" ]; then
    echo "post-receive: skipping $refname (deploying only $DEPLOY_REF)"
    continue
  fi
  if [ "$newrev" = "0000000000000000000000000000000000000000" ]; then
    echo "post-receive: $refname deleted, nothing to deploy"
    continue
  fi
  echo "post-receive: deploying $newrev to $WORK_TREE"
  git --work-tree="$WORK_TREE" --git-dir="$REPO" checkout -q -f "${refname#refs/heads/}"
done
EOF
sudo chmod 755 /srv/site.git/hooks/post-receive
ls -l /srv/site.git/hooks/post-receive

Linia po linii. DEPLOY_REF to gałąź, na którą wskazuje HEAD repozytorium bare, czyli ta z -b main. Każda inna wypchnięta gałąź jest pomijana z komunikatem, więc push gałęzi testowej na serwer niczego nie zepsuje. Czterdzieści zer w newrev oznacza, że gałąź została usunięta, i wtedy nie ma czego wdrażać. Właściwa praca to jedna linia: checkout -f bierze drzewo plików z wypchniętego commita i wymusza jego stan w /srv/www/site. Flaga -f nadpisuje pliki zmienione ręcznie na serwerze, a -q wycisza komunikaty gita o przełączaniu gałęzi, tak żeby w terminalu zostały tylko linie z hooka i ewentualne błędy.

Zwróć uwagę na jawne --git-dir="$REPO" zamiast polegania na bieżącym katalogu. Git uruchamia hooki ze zmienną środowiskową GIT_DIR ustawioną na ., czyli ścieżkę względną. Popularny „prostszy” hook w stylu cd /srv/www/site && git pull wywraca się na tym z błędem fatal: not a git repository: '.', bo po cd kropka wskazuje już inny katalog. Jawna ścieżka usuwa cały problem.

Hook musi być wykonywalny i możliwy do odczytu przez użytkownika deploy, bo to on go uruchamia. tee uruchomione przez sudo -u deploy tworzy plik jako deploy, a chmod 755 dodaje bit wykonywalności. ls -l powinien pokazać uprawnienia zaczynające się od -rwxr-xr-x i właściciela deploy deploy.

Krok 4: pierwszy push, na razie lokalną ścieżką

Zanim wejdzie w to SSH, przetestuj mechanizm na samym serwerze. Adresem zdalnego repozytorium może być zwykła ścieżka w systemie plików. Git uruchomi wtedy git-receive-pack lokalnie, dokładnie ten sam program i ten sam hook, co przy pushu przez sieć. Zrób to jako deploy, bo to jego repozytorium.

sudo -iu deploy
git init -b main ~/demo
cd ~/demo
git config user.name "Deploy Test"
git config user.email "deploy@example.com"
echo '<h1>Deploy przez git push dziala</h1>' > index.html
git add index.html
git commit -m "Pierwsza wersja strony"
git push /srv/site.git main
exit

git config user.name i user.email są potrzebne, bo na świeżym koncie git odmówi zrobienia commita bez tożsamości autora. W wyniku pusha, wśród zwykłych linii o liczeniu obiektów, powinna pojawić się linia remote: post-receive: deploying <sha> to /srv/www/site. Prefiks remote: oznacza, że tekst wydrukował hook po stronie odbierającej, nawet jeśli obie strony są na tym samym komputerze.

Nie ufaj jednak wyjściu gita. Sprawdź katalog strony:

ls -la /srv/www/site
cat /srv/www/site/index.html

ls -la pokazuje index.html z właścicielem deploy, i nie pokazuje katalogu .git. To druga przewaga nad klonowaniem do public_html: historia repozytorium leży w /srv/site.git, poza katalogiem, który serwuje nginx, więc nie ma czego blokować. cat wypisuje linię z nagłówkiem h1, którą przed chwilą zacommitowałeś.

Teraz zmiana i usunięcie pliku, bo to drugie często zaskakuje:

sudo -iu deploy
cd ~/demo
echo '<h1>Wersja 2</h1>' > index.html
echo 'User-agent: *' > robots.txt
git add -A
git commit -m "Wersja 2 i robots.txt"
git push /srv/site.git main
git rm -q robots.txt
git commit -m "Usuwam robots.txt"
git push /srv/site.git main
exit
ls /srv/www/site
cat /srv/www/site/index.html

Po drugim pushu ls pokazuje tylko index.html, a cat nową treść. robots.txt zniknął, bo checkout -f porównuje nowy commit z tym, co wdrożył poprzednio. Ten stan trzyma w pliku /srv/site.git/index. Plik, który był w poprzednim commicie, a nie ma go w nowym, jest usuwany z katalogu roboczego. Plików, których git nigdy nie śledził, na przykład katalogu z uploadami, checkout nie dotyka.

Push z laptopa: ten sam mechanizm przez SSH

Na laptopie, w repozytorium projektu, dodaj serwer jako zdalne repozytorium i wypchnij gałąź:

git remote add prod deploy@203.0.113.10:/srv/site.git
git push -u prod main

Adres deploy@203.0.113.10:/srv/site.git to użytkownik, host i ścieżka na serwerze. Git łączy się przez SSH jako deploy i uruchamia tam git-receive-pack '/srv/site.git'. Od tego momentu wszystko dzieje się tak samo, jak w teście lokalną ścieżką: te same obiekty i ten sam hook. Zmienił się tylko transport. Jeśli push kończy się na Permission denied (publickey), oznacza to, że klucz z laptopa nie jest w authorized_keys użytkownika deploy. Przejdź wtedy przez diagnozę błędu Permission denied (publickey) dokładnie tak, jak dla zwykłego logowania.

Dwie wygody. git push -u zapamiętuje, że lokalna main śledzi prod/main, więc od teraz wystarczy git push prod. A jeśli chcesz wdrożyć jeszcze raz tę samą wersję, na przykład po ręcznej poprawce na serwerze, samo git push nic nie zrobi, bo wypisze Everything up-to-date i hook nie wystartuje. Zrób pusty commit git commit --allow-empty -m "redeploy" i wypchnij go, albo uruchom na serwerze polecenie checkout z hooka ręcznie jako deploy.

Co po checkout: restart usługi albo docker compose

Dla strony statycznej serwowanej przez nginx to koniec. Nginx czyta pliki z dysku przy każdym żądaniu, więc nowa wersja jest widoczna natychmiast. Jeśli w bloku server ustawisz root /srv/www/site;, katalog roboczy jest jednocześnie katalogiem strony.

Dla aplikacji, na przykład w Pythonie albo Node.js, po checkout trzeba zrestartować proces. Użytkownik deploy nie ma sudo i nie powinien go dostać w całości. Daj mu prawo do jednego, dokładnie określonego polecenia:

echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart site.service' | sudo tee /etc/sudoers.d/deploy
sudo chmod 440 /etc/sudoers.d/deploy
sudo visudo -cf /etc/sudoers.d/deploy

visudo -cf sprawdza składnię pliku i powinien wypisać /etc/sudoers.d/deploy: parsed OK. Potem w hooku, po linii z checkout, dodaj sudo -n /usr/bin/systemctl restart site.service. Flaga -n sprawia, że sudo nie zapyta o hasło, tylko od razu zwróci błąd, jeśli reguła nie pasuje. Bez niej hook zawiśnie na niewidocznym pytaniu o hasło. Polecenie w sudoers i w hooku muszą się zgadzać co do znaku, razem z pełną ścieżką i argumentami.

Aplikacja w kontenerach potrzebuje jednej linii: docker compose -f "$WORK_TREE/compose.yaml" up -d --build. Pamiętaj, że członkostwo w grupie docker jest równoważne rootowi, więc dając je użytkownikowi deploy, oddajesz to, co zyskałeś w kroku 1. W obu wariantach nginx stoi z przodu jako reverse proxy, a konfiguracja reverse proxy w nginx nie zmienia się przy kolejnych wdrożeniach, bo aplikacja słucha wciąż na tym samym porcie.

Jedna właściwość post-receive, o której trzeba wiedzieć: uruchamia się, kiedy gałęzie na serwerze są już zaktualizowane, więc jego błąd nie cofa pusha. Jeśli checkout albo restart się nie powiedzie, git push i tak zakończy się sukcesem z punktu widzenia gita, a błąd zobaczysz tylko w liniach z prefiksem remote:. Czytaj je.

Dlaczego pierwszy push nie wdraża: błędy, które zobaczysz

Push przechodzi, katalog strony jest pusty, a w wyjściu jest linia remote: hint: The 'hooks/post-receive' hook was ignored because it's not set as executable. Git sprawdza bit wykonywalności i po cichu pomija hook, który go nie ma, dodając tylko tę wskazówkę. Ten sam efekt daje hook należący do roota z uprawnieniami 700, bo deploy nie może go wtedy ani odczytać, ani uruchomić. Napraw przez sudo chown deploy:deploy /srv/site.git/hooks/post-receive i sudo chmod 755 /srv/site.git/hooks/post-receive.

Hook startuje, ale kończy się na remote: error: unable to create file index.html: Permission denied. Katalog /srv/www/site nie jest zapisywalny dla deploy. Tak wygląda katalog utworzony zwykłym sudo mkdir, bez zmiany właściciela. Sprawdź ls -ld /srv/www/site: właścicielem musi być deploy albo grupa, w której deploy ma prawo zapisu. sudo chown -R deploy:deploy /srv/www/site załatwia sprawę.

Push kończy się na fatal: detected dubious ownership in repository at '/srv/site.git'. Repozytorium bare zostało utworzone jako root, a git-receive-pack działa jako deploy. Git odmawia, bo cudze repozytorium może zawierać hooki podstawione przez kogoś innego. Nie dodawaj wyjątku safe.directory, tylko oddaj repozytorium właściwemu użytkownikowi: sudo chown -R deploy:deploy /srv/site.git.

Push przechodzi, hook drukuje post-receive: skipping refs/heads/main (deploying only refs/heads/master). Repozytorium bare powstało bez -b main, więc jego HEAD wskazuje na master, a ty wypychasz main. Przestaw HEAD bez ruszania niczego innego: sudo -u deploy git --git-dir=/srv/site.git symbolic-ref HEAD refs/heads/main, po czym wypchnij pusty commit, żeby hook uruchomił się ponownie.

Hook z własnym cd i git pull kończy się na fatal: not a git repository: '.'. Zmienna GIT_DIR=. ze środowiska hooka po cd wskazuje na zły katalog. Dodaj unset GIT_DIR na początku skryptu albo, jak w hooku wyżej, podawaj --git-dir jawnie.

Kiedy przesiąść się na Gitea, Forgejo albo CI

Ten hook wystarcza jednej osobie z jedną stroną, a często i małemu zespołowi. Nie ma w nim historii wdrożeń, przeglądu kodu, zgłoszeń ani automatycznych testów, bo to nie jest jego zadanie. Potrzebujesz pull requestów i uprawnień per gałąź, kiedy pushuje więcej osób. Potrzebujesz CI, kiedy przed wdrożeniem trzeba uruchomić testy albo zbudować obraz, a robienie tego w hooku zaczyna trwać minuty. W obu przypadkach własny serwer git, taki jak Gitea albo Forgejo daje interfejs i runner do zadań, a sam krok wdrożenia dalej może być tym samym git push do /srv/site.git, tyle że wykonywanym przez runner zamiast z laptopa. Jeśli nie jesteś pewien, czy w ogóle potrzebujesz czegoś więcej niż gita na swoim VPS, porównanie git, GitHub i własny serwer git pokazuje, co dokładnie dokłada każda z tych warstw.

Większość osób z jedną stroną nigdy nie musi wyrastać z tych kilkunastu linii. Cały mechanizm da się zrozumieć w całości, od pierwszej do ostatniej linii, i to jest jego największa zaleta.

FAQ

Dlaczego po git push katalog strony jest pusty?

Najczęściej hook post-receive nie ma bitu wykonywalności albo należy do roota, i git go pomija. W wyjściu pusha szukaj linii remote: hint: The 'hooks/post-receive' hook was ignored because it's not set as executable.. Napraw przez sudo chown deploy:deploy /srv/site.git/hooks/post-receive i sudo chmod 755 /srv/site.git/hooks/post-receive, a potem wypchnij pusty commit git commit --allow-empty -m "redeploy", bo samo git push bez nowych commitów nie uruchomi hooka. Drugi powód to HEAD repozytorium bare wskazujący na inną gałąź niż ta, którą wypychasz. Hook, który wdraża tylko gałąź z HEAD, drukuje wtedy post-receive: skipping refs/heads/main, a naprawą jest git --git-dir=/srv/site.git symbolic-ref HEAD refs/heads/main uruchomione jako deploy.

Czy mogę pchać kilka gałęzi, na przykład staging i produkcję?

Tak, ale najprościej przez dwa osobne repozytoria bare z dwoma hookami, na przykład /srv/site-staging.git z katalogiem roboczym /srv/www/site-staging. Każde ma własny HEAD i własny katalog, więc pomyłka w nazwie gałęzi nie wdroży wersji testowej na produkcję. Jeden hook rozgałęziający się po refname też działa, ale z każdą kolejną gałęzią staje się mniej czytelny.

Czy hook może odrzucić push, jeśli testy nie przejdą?

post-receive nie może, bo uruchamia się, kiedy gałęzie są już zaktualizowane. Do odrzucania służy hook pre-receive albo update, który dostaje te same dane o referencjach i zatrzymuje push niezerowym kodem wyjścia. W praktyce testy przed wdrożeniem to moment, w którym warto przejść na runner CI w Gitea albo Forgejo, zamiast rozbudowywać skrypt w hooks/.

Czym się to różni od klonowania repozytorium do public_html na hostingu?

Na hostingu współdzielonym git jest narzędziem do pobierania plików: klonujesz, a potem po każdej zmianie logujesz się i robisz git pull, a katalog .git leży w katalogu publicznym. Na VPS push z laptopa sam uruchamia wdrożenie, repozytorium leży poza katalogiem serwowanym przez nginx, a całość działa jako osobny użytkownik bez sudo. Mechanizm gita jest ten sam. Różnica polega na tym, że kontrolujesz obie strony połączenia.