jchs.software działa na maszynach w moim homelabie. DevOps to moje hobby, a własna strona to bezpieczne miejsce, żeby je ćwiczyć.
Poniżej droga, którą commit przechodzi od git push do produkcji.
W skrócie
- Robię push na
main. - Zadanie bierze runner GitHub Actions na mojej własnej maszynie.
- Runner instaluje zależności, puszcza linter i testy, buduje stronę i sprawdza budżety JavaScriptu.
- Podman buduje obraz kontenera i wysyła go do prywatnego rejestru.
- Runner mówi serwerowi z aplikacją, który commit ma działać. Resztę robi serwer.
Pull requesty też przechodzą kroki od 2 do 4, więc każdy PR ma swój obraz. Do kroku 5 dochodzi tylko push na main.
Build
Strona to aplikacja Next.js budowana z output: "standalone". Wynikiem jest server.js i tylko te paczki, których naprawdę używa, więc obraz jest mały.
Cały Containerfile:
FROM node:24-alpine
WORKDIR /app
COPY .next/standalone ./
COPY public ./public
COPY .next/static ./public/_next/static
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]
Build odbywa się na runnerze. Obraz tylko kopiuje jego wynik. Oba używają tej samej głównej wersji Node, więc testuję dokładnie to, co potem działa.
Sprawdzenia przed obrazem
Cztery kroki mogą zatrzymać przebieg, zanim powstanie jakikolwiek obraz:
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build Next.js (standalone)
run: npm run build
- name: Check bundle budgets
run: npm run check:bundle
Ostatni to mały skrypt. Sumuje rozmiar gzip JavaScriptu, który strona ładuje na starcie, i kończy się błędem, gdy suma przekracza budżet. Biurko 3D ładuje się później i ma własny budżet.
Przeniesienie tego sprawdzenia do CI czegoś mnie nauczyło. Ten sam build, bajt w bajt, na Node 24 ważył o 206 bajtów więcej niż na Node 22, bo każda z tych wersji ma inną wersję zlib. U siebie miałem 44 bajty zapasu. Na Node 24, czyli na wersji z CI, byłem 162 bajty ponad limitem.
Limit zostawiłem i wyciąłem kod. Przełącznik języka używał next/link, ale przejście z / na /pl i tak przeładowuje całą stronę, więc zwykły <a> robi to samo przy 3 KB mniej JavaScriptu.
Tagi obrazu
Każdy obraz dostaje więcej niż jeden tag:
| Tag | Kiedy | Po co |
|---|---|---|
sha- + 7 znaków |
zawsze | nigdy się nie przesuwa, więc mogę wrócić do każdego builda |
pr- + numer |
pull requesty | najnowszy build danego PR-a |
main i latest |
push na main |
to, co działa na produkcji |
vX.Y.Z |
tagi gita | wydania |
Wdrożenie
Runner i aplikacja stoją na różnych maszynach. Runner łączy się po SSH kluczem, który służy tylko do wdrożeń. Na serwerze ten klucz jest przypięty do jednego skryptu, więc nie da się nim otworzyć powłoki.
CI wysyła jedną rzecz, SHA commita:
- name: Deploy
run: ssh -i "$HOME/.ssh/$SSH_KEY" -o BatchMode=yes "$REMOTE_USER@$REMOTE_HOST" "$GITHUB_SHA"
Dalej działa skrypt na serwerze. Na koniec sprawdza, czy uruchomiona wersja to dokładnie ten commit.
Wdrożenia z main idą jedno po drugim i nigdy nie są przerywane w połowie. Buildy PR-ów działają odwrotnie: nowy push anuluje poprzedni build.
Co stoi przed aplikacją
Żądania idą przez Cloudflare do gatewaya w mojej sieci. Nginx na gatewayu jest reverse proxy i przekazuje je do maszyny wirtualnej, na której działa kontener.
I to wszystko
Około stu linii YAML-a, krótki Containerfile i jeden skrypt na serwerze.