🚀 Wdrożenie aplikacji Laravel + Next.js na k3s - kompletny przewodnik

Poziom: Junior / Mid · Wymagania wstępne: Docker, podstawy CLI, własna domena
Czas czytania: ~30 min · Czas wdrożenia: ~2 godz. (przy pierwszym razie)


Jeśli do tej pory wdrażałeś aplikacje przez docker-compose up na VPS i zastanawiasz się, kiedy i jak przejść na Kubernetes - ten artykuł jest dla Ciebie. Pokażę Ci, jak wdrożyć pełny stack (Laravel API + admin SPA, Next.js frontend, MySQL, Redis, Gotenberg PDF) na k3s - lekkim Kubernetes idealnym na pojedynczy VPS.

Nie zakładam, że znasz Kubernetes. Zakładam, że znasz Dockera i nie boisz się terminala.


Spis treści

  1. Czym jest k3s i dlaczego nie zwykły k8s?
  2. Architektura tego wdrożenia
  3. Przygotowanie serwera (Hetzner)
  4. Instalacja k3s
  5. Instalacja cert-manager (SSL Let's Encrypt)
  6. Konfiguracja Traefik (HTTPS + przekierowanie)
  7. Przygotowanie klastra - namespace i sekrety
  8. Wdrożenie bazy danych i cache (MySQL + Redis)
  9. Wdrożenie Gotenberg (PDF)
  10. Wdrożenie Typesense (full-text search)
  11. Pull Secret dla Container Registry (GHCR / GitLab)
  12. Wdrożenie aplikacji (serwer + klient)
  13. Jak CI/CD łączy się z k3s?
  14. Konfiguracja GitHub Actions
  15. Konfiguracja GitLab CI/CD
  16. Pierwsze wdrożenie przez CI
  17. Weryfikacja - czy wszystko działa?
  18. Codzienna obsługa - logi, restarty, aktualizacje
  19. Backup MySQL
  20. Najczęstsze problemy (troubleshooting)
  21. GlitchTip - śledzenie błędów
  22. Rancher - zarządzanie klastrem przez UI
  23. Czyszczenie dysku
  24. k9s - terminalowy panel zarządzania
  25. Rotacja secretów - aktualizacja .env i haseł bez downtime'u
  26. Reset serwera pod inną aplikację
  27. Kod źródłowy
  28. Podsumowanie

1. Czym jest k3s i dlaczego nie zwykły k8s?

Kubernetes (k8s) to system orkiestracji kontenerów - mówisz mu co chcesz uruchomić, a on pilnuje, żeby to działało. Jeśli kontener padnie, Kubernetes go restartuje. Chcesz zaktualizować aplikację bez przestoju? Kubernetes zaktualizuje po jednym podzie na raz.

k3s to Kubernetes odchudzony do minimum przez Rancher Labs (teraz SUSE). Usuwa zbędne sterowniki chmurowe, zastępuje etcd lżejszą bazą SQLite (albo embedded etcd dla HA), pakuje wszystko w jeden binarny plik ~70 MB. API jest w 100% kompatybilne z pełnym k8s - te same manifesty YAML, ten sam kubectl.

Porównanie zasobów

Pełny k8s (kubeadm)k3s
RAM samego control plane~2 GB~512 MB
Instalacja~30 kroków1 komenda
Wymagany serwermin. 4 węzły1 węzeł
Kompatybilność z k8s100%100%

Na pojedynczym VPS z 8 GB RAM k3s to jedyna rozsądna opcja.

Dlaczego nie docker-compose?

Docker Compose sprawdza się świetnie na lokalnym środowisku. Na produkcji brakuje mu jednak:

  • Automatycznego restartu po OOM lub crashu kontenera z logiką retry/backoff
  • Zero-downtime deployów - docker-compose up zatrzymuje kontener zanim uruchomi nowy
  • Health check gate - Kubernetes nie przekieruje ruchu do poda, dopóki nie odpowie /health
  • Migracji przed deployem - możesz uruchomić Job z migracją i poczekać na jego zakończenie przed rolloutem
  • Rollback jedną komendą

2. Architektura tego wdrożenia

Internet
    │
    ▼
[ Traefik ] ← wbudowany w k3s, zajmuje się TLS + routing
    │
    ├──► app-client (Next.js :3000)       ← publiczny frontend, domyślny APP_NAME=app
    │
    └──► app-server (Laravel/Nginx :80)   ← API + admin panel, domyślny APP_NAME=app
              │
              ├── app-mysql (MySQL 8)     ← StatefulSet + PVC
              ├── app-redis (Redis 7)     ← Deployment + PVC
              └── app-gotenberg           ← generowanie PDF

Domyślnie wszystko działa w namespace app i z prefiksem zasobów app-*. Możesz to zmienić jednym mechanizmem: APP_NAME ustawia prefiks zasobów, KUBE_NAMESPACE ustawia namespace. GitHub Actions jest główną ścieżką deploymentu i buduje obrazy w GHCR; GitLab CI zostaje alternatywą.


3. Przygotowanie serwera (Hetzner)

3.1 Utwórz serwer

Jeśli nie masz jeszcze klucza SSH

Klucz SSH tworzysz na lokalnym komputerze, nie na serwerze. Jeśli plik ~/.ssh/id_ed25519.pub już istnieje, możesz pominąć ten krok.

Sprawdź, czy masz już klucz:

ls -la ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub

Jeśli pliki nie istnieją, wygeneruj nowy klucz:

ssh-keygen -t ed25519 -C "[email protected]"

Gdy ssh-keygen zapyta o ścieżkę, zostaw domyślną:

~/.ssh/id_ed25519

Passphrase jest opcjonalne, ale zalecane. Jeśli je ustawisz, system będzie pytał o hasło do klucza przy pierwszym użyciu w sesji.

Wyświetl klucz publiczny:

cat ~/.ssh/id_ed25519.pub

Skopiuj cały wynik zaczynający się od ssh-ed25519. To właśnie ten publiczny klucz wklejasz w panelu Hetzner. Nigdy nie kopiuj ani nie wysyłaj pliku ~/.ssh/id_ed25519 - to klucz prywatny.

W panelu Hetzner Cloud (console.hetzner.cloud):

  1. Location: Falkenstein (Europa - niskie ping z Polski)
  2. Image: Ubuntu 24.04 LTS
  3. Type: CX33 (4 vCPU, 8 GB RAM, 80 GB NVMe) - ~$10/mc
  4. Networking: Włącz publiczne IPv4 + IPv6
  5. SSH key: Wklej swój klucz publiczny (cat ~/.ssh/id_ed25519.pub)
  6. Firewall: Utwórz nowy z regułami:
TypProtokółPortŹródło
InboundTCP22Twój IP (lub 0.0.0.0/0 jeśli dynamiczny)
InboundTCP800.0.0.0/0, ::/0
InboundTCP4430.0.0.0/0, ::/0
InboundTCP6443Twój IP (kubectl API)

Port 6443 to API serwera Kubernetes. Ogranicz go do swojego IP - nie ma powodu, żeby był publiczny.

Jeśli zapomniałeś dodać klucz SSH przy tworzeniu serwera

Najprościej naprawić to, zanim wyłączysz logowanie hasłem.

Jeśli możesz zalogować się hasłem jako root, dodaj klucz z lokalnego komputera:

ssh-copy-id -i ~/.ssh/id_ed25519.pub root@<IP_SERWERA>

Potem sprawdź logowanie kluczem:

ssh -i ~/.ssh/id_ed25519 root@<IP_SERWERA>

Jeśli ssh-copy-id nie jest dostępne, zrób to ręcznie:

# Na lokalnym komputerze
cat ~/.ssh/id_ed25519.pub

Skopiuj wynik, zaloguj się na serwer hasłem przez SSH albo konsolę Hetzner, a potem uruchom:

# Na serwerze
mkdir -p ~/.ssh
nano ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

Wklej publiczny klucz do authorized_keys, zapisz plik i przetestuj nowe logowanie z drugiego terminala.

Jeśli nie możesz zalogować się ani hasłem, ani kluczem, użyj konsoli Hetzner lub trybu Rescue, dodaj klucz do /root/.ssh/authorized_keys, a w najgorszym przypadku przebuduj serwer z poprawnie dodanym kluczem. Nie wyłączaj PasswordAuthentication, dopóki nie potwierdzisz, że logowanie kluczem działa.

3.2 Zaloguj się i zaktualizuj system

ssh root@<IP_SERWERA>

sudo apt update && apt upgrade -y
sudo apt install -y curl wget git htop vim

3.3 Ustaw hostname

hostnamectl set-hostname app
echo "127.0.0.1 app" >> /etc/hosts

3.4 Konfiguracja DNS

W panelu swojego rejestratora domen ustaw rekordy A:

yourdomain.com        A  <IP_SERWERA>
www.yourdomain.com    A  <IP_SERWERA>
admin.yourdomain.com  A  <IP_SERWERA>

Panel admina i API Laravel działają pod tą samą domeną admin.yourdomain.com - serwer obsługuje zarówno /admin/* (Inertia SPA) jak i /api/v1/* (REST API).

Poczekaj ~5-15 minut na propagację DNS. Możesz sprawdzić:

dig +short yourdomain.com
# powinno zwrócić IP serwera

3.5 SSH - konfiguracja na lokalnym komputerze

Zanim zalogujesz się na serwer, skonfiguruj wygodny alias SSH na lokalnym komputerze. Dzięki temu zamiast pisać ssh [email protected] za każdym razem, wystarczy ssh app.

Edytuj (lub utwórz) plik ~/.ssh/config:

nano ~/.ssh/config

Dodaj:

Host app
    HostName <IP_SERWERA>
    User root
    IdentityFile ~/.ssh/id_ed25519

Od teraz:

ssh app                  # logowanie
scp plik.txt app:/tmp/   # kopiowanie pliku

Jeśli później utworzysz użytkownika deployer (sekcja 3.6), zmień User root na User deployer.

3.6 SSH - hardening serwera (wyłącz logowanie hasłem)

Po zalogowaniu na serwer pierwszym krokiem powinno być wyłączenie logowania hasłem - klucz SSH jest znacznie bezpieczniejszy.

Opcja A: tylko root z kluczem (prostsze)

Wystarczająca dla jednoosobowego VPS. Zaloguj się i edytuj konfigurację SSH:

ssh app   # lub: ssh root@<IP_SERWERA>

nano /etc/ssh/sshd_config

Upewnij się, że te linie wyglądają tak:

PasswordAuthentication no
PermitRootLogin prohibit-password

Zrestartuj SSH:

systemctl restart ssh

⚠️ Zanim zamkniesz obecną sesję - otwórz drugą sesję SSH i sprawdź, czy możesz się zalogować. Jeśli tak, dopiero zamknij pierwszą.

Opcja B: dedykowany użytkownik deployer + sudo (zalecane przy kilku osobach)

Tworzy osobne konto z uprawnieniami sudo - root login zostaje całkowicie zablokowany.

# Na serwerze (jako root)
adduser deployer                            # utwórz użytkownika (ustawi hasło interaktywnie)
usermod -aG sudo deployer                   # dodaj do grupy sudo

# Skopiuj klucz SSH z roota na nowego użytkownika
mkdir -p /home/deployer/.ssh
cp ~/.ssh/authorized_keys /home/deployer/.ssh/
chown -R deployer:deployer /home/deployer/.ssh
chmod 700 /home/deployer/.ssh
chmod 600 /home/deployer/.ssh/authorized_keys

Teraz przetestuj logowanie jako deployer w nowej sesji:

# Na lokalnym komputerze - nowa sesja terminala
ssh -i ~/.ssh/id_ed25519 deployer@<IP_SERWERA>
sudo whoami   # powinno zwrócić: root

Jeśli działa - zablokuj root login:

# Na serwerze (jako deployer, przez sudo)
sudo nano /etc/ssh/sshd_config
PasswordAuthentication no
PermitRootLogin no
sudo systemctl restart ssh

Zaktualizuj alias w ~/.ssh/config na lokalnym komputerze:

Host app
    HostName <IP_SERWERA>
    User deployer
    IdentityFile ~/.ssh/id_ed25519

4. Instalacja k3s

4.1 Zainstaluj k3s

Zaloguj się na serwer i uruchom instalator. k3s musi być zainstalowany jako root - niezależnie od tego, czy wybrałeś Opcję A czy B w sekcji 3.6.

# Opcja A (root): jesteś już zalogowany jako root
ssh root@<IP_SERWERA>

# Opcja B (deployer): zaloguj się i przejdź na roota
ssh deployer@<IP_SERWERA>
sudo -i

Uruchom instalator k3s z flagą K3S_KUBECONFIG_MODE="644" - dzięki temu plik kubeconfig będzie czytelny dla wszystkich użytkowników serwera (potrzebne w sekcji 4.2, żeby skopiować go bez sudo):

Hetzner Cloud (z hcloud-controller-manager) - CCM sam prowizjonuje prawdziwy Load Balancer, więc wewnętrzny LB k3s jest zbędny:

curl -sfL https://get.k3s.io | K3S_KUBECONFIG_MODE="644" sh -s - --disable=servicelb

Inne providery (OVHcloud, DigitalOcean, Vultr, bare metal itp.) - bez chmurowego LB potrzebujesz wbudowanego servicelb k3s, który binduje porty 80/443 bezpośrednio na hoście. Nie dodawaj --disable=servicelb:

curl -sfL https://get.k3s.io | K3S_KUBECONFIG_MODE="644" sh -s -

Co to jest servicelb?
servicelb (klipper) to wewnętrzny load balancer k3s. Tworzy pody DaemonSet, które nasłuchują na portach 80/443 hosta i przekazują ruch do Traefika. Bez niego - i bez chmurowego LB - porty 80/443 są niedostępne z internetu, Let's Encrypt HTTP-01 challenge nigdy nie przejdzie i certyfikat TLS nie zostanie wystawiony.

Traefiku nie wyłączamy - k3s instaluje go automatycznie, a HelmChartConfig z sekcji 4.3 dostroi jego konfigurację (HTTP→HTTPS redirect).

Poczekaj ~30 sekund, a następnie sprawdź (nadal jako root na serwerze):

kubectl get nodes
# NAME   STATUS   ROLES                  AGE   VERSION
# app    Ready    control-plane,master   1m    v1.31.x+k3s1

Status Ready - możesz wyjść z SSH.

4.2 Skopiuj kubeconfig na lokalny komputer

Na lokalnym komputerze (nie na serwerze). Komenda jest taka sama niezależnie od tego, czy używasz root czy deployer - dzięki K3S_KUBECONFIG_MODE="644" z poprzedniego kroku plik jest czytelny bez sudo:

mkdir -p ~/.kube

# root lub deployer - ta sama komenda:
ssh <USER>@<IP_SERWERA> "cat /etc/rancher/k3s/k3s.yaml" \
  | sed "s/127.0.0.1/<IP_SERWERA>/g" \
  > ~/.kube/config-hetzner

Gdzie <USER> to root (Opcja A) lub deployer (Opcja B).

chmod 600 ~/.kube/config-hetzner
export KUBECONFIG=~/.kube/config-hetzner

Zweryfikuj połączenie:

kubectl get nodes
# app   Ready   control-plane,master   2m

Dodaj do ~/.bashrc lub ~/.zshrc:
export KUBECONFIG=~/.kube/config-hetzner
żeby nie musieć ustawiać za każdym razem.

Od teraz nie potrzebujesz już SSH - wszystkie dalsze komendy kubectl wykonujesz lokalnie przez port 6443.

4.3 Zainstaluj Traefik i ServiceLB przez Helm

Lokalny komputer - od tej chwili wszystkie komendy kubectl wykonujesz u siebie.
kubectl to klient HTTP - wysyła polecenia do serwera przez port 6443. Nie musisz być zalogowany przez SSH. Upewnij się, że masz ustawione export KUBECONFIG=~/.kube/config-hetzner (sekcja 4.2).

Instalacja Traefiku składa się z dwóch kroków - Middleware musi być aplikowany dopiero po tym, jak Traefik zainstaluje swoje CRD (Custom Resource Definitions).

Krok 1 - zastosuj konfigurację Traefiku (tylko HelmChartConfig):

kubectl apply -f k8s/traefik/config.yaml

Poczekaj, aż Traefik będzie uruchomiony:

kubectl -n kube-system get pods --watch | grep traefik
# traefik-xxxxxxxxx-xxxxx   1/1   Running   0   1m

Wyjdź z --watch przez Ctrl+C gdy zobaczysz Running.

Middleware (k8s/traefik/middleware.yaml) aplikujemy dopiero w sekcji 7.1 - po utworzeniu namespace. Middleware musi należeć do istniejącego namespace.

4.4 Automatyczna konfiguracja klastra - bootstrap.sh

Po zainstalowaniu k3s i skopiowaniu kubeconfig (sekcje 4.1–4.2) masz dwie opcje:

OpcjaKiedy wybrać
A: bootstrap.sh (zalecana)Nowy klaster, chcesz przejść szybko - skrypt wykona konfigurację klastra automatycznie
B: Ręcznie (sekcje 5–14)Uczysz się Kubernetes, chcesz pełną kontrolę nad każdym krokiem

Opcja A - uruchom bootstrap.sh

Wymagania przed uruchomieniem:

  • KUBECONFIG wskazuje na klaster (sekcja 4.2)
  • Repozytorium jest sklonowane lokalnie (katalog k8s/ musi istnieć)
  • Rekordy DNS wskazują na IP serwera (sekcja 3.4)

Z katalogu głównego repozytorium na lokalnym komputerze:

chmod +x k8s/bootstrap.sh
./k8s/bootstrap.sh

Skrypt interaktywnie zapyta o:

PytanieCo wpisać
Application name / prefixDomyślnie app; zasoby będą miały prefiks <APP_NAME>-
Kubernetes namespaceDomyślnie taki sam jak APP_NAME; można podać inny namespace
Hasło MySQL rootSilne hasło, min. 16 znaków
Nazwa użytkownika MySQLDomyślnie app
Hasło MySQL appSilne hasło, min. 16 znaków
Hasło RedisSilne hasło, min. 16 znaków
Typesense API keyLosowy klucz, min. 16 znaków
Typ CI/CD1 = GitHub Actions, 2 = GitLab CI, Enter = pomiń
GitHub/GitLab tokenDo pull secret dla prywatnego rejestru obrazów
E-mail Let's EncryptPowiadomienia o wygasaniu certyfikatów
Ingress deweloperski (HTTP)y jeśli nie masz domeny - używa sslip.io
Ingress produkcyjny (HTTPS)Y (domyślnie) - wymaga DNS + cert-manager
GlitchTipy jeśli chcesz self-hosted śledzenie błędów
Ops tooling (Rancher + Uptime Kuma)y jeśli chcesz panel UI klastra i monitoring dostępności - odpala docker-compose.ops.yml na serwerze

Czas wykonania: ~5–10 minut (większość to oczekiwanie na MySQL i cert-manager).

Po zakończeniu skrypt wyświetli listę podów w namespace app oraz dalsze kroki - konfigurację sekretów CI/CD.

Jeśli wybrałeś Opcję A - przejdź do sekcji 13 (Jak CI/CD łączy się z k3s?). Sekcje 5–12 poniżej opisują to samo co robi skrypt - przydatne jako dokumentacja lub przy ręcznej rekonfiguracji.


5. Instalacja cert-manager (SSL Let's Encrypt)

cert-manager automatycznie wystawia i odnawia certyfikaty TLS od Let's Encrypt.

5.1 Zainstaluj cert-manager

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml

Poczekaj, aż wszystkie pody będą Running:

kubectl -n cert-manager get pods --watch
# cert-manager-xxxx             1/1   Running
# cert-manager-cainjector-xxxx  1/1   Running
# cert-manager-webhook-xxxx     1/1   Running

5.2 Utwórz ClusterIssuer

ClusterIssuer to konfiguracja mówiąca cert-managerowi, jak wystawiać certyfikaty. Stwórz plik letsencrypt-prod.yaml:

# letsencrypt-prod.yaml (nie commituj do repo - jednorazowe polecenie)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]          # <-- ZMIEŃ na swój e-mail
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik

Zastosuj:

kubectl apply -f letsencrypt-prod.yaml

Sprawdź status:

kubectl get clusterissuer letsencrypt-prod
# NAME               READY   AGE
# letsencrypt-prod   True    30s

READY: True = cert-manager jest gotowy do wystawiania certyfikatów.


6. Konfiguracja Traefik (HTTPS + przekierowanie)

Plik k8s/traefik/config.yaml zawiera dwie rzeczy:

  1. HelmChartConfig - konfiguruje Traefik tak, żeby ruch HTTP (port 80) był automatycznie przekierowywany na HTTPS (port 443)
  2. Middleware - ustawia limit rozmiaru ciała żądania na 100 MB (potrzebne do uploadów plików)

Jeśli wcześniej zastosowałeś ten plik, Traefik już jest skonfigurowany. Zweryfikuj:

kubectl -n kube-system get helmchartconfig traefik
# NAME      AGE
# traefik   5m

7. Przygotowanie klastra - namespace i sekrety

Uwaga o nazwie i namespace: Manifesty w k8s/ są szablonami renderowanymi przez k8s/render.sh. Domyślne wartości to APP_NAME=app i KUBE_NAMESPACE=app, więc przykłady z app-* pokazują zachowanie domyślne. Jeśli używasz innego namespace, zastąp w przykładach app wartością KUBE_NAMESPACE; jeśli używasz innego prefiksu zasobów, zastąp app-* przez <APP_NAME>-*.

Jedno miejsce konfiguracji: w GitHub Actions ustaw Variables APP_NAME, KUBE_NAMESPACE, PROD_ENV, ENV_CLIENT_PROD; lokalnie bootstrap.sh pyta o APP_NAME i KUBE_NAMESPACE; wszystkie aplikacyjne YAML-e przechodzą przez k8s/render.sh.

7.1 Utwórz namespace

Namespace to izolowana przestrzeń w klastrze - jak osobny folder dla naszej aplikacji.

APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh k8s/namespace.yaml | kubectl apply -f -
kubectl get namespace app
# NAME       STATUS   AGE
# app   Active   5s

Teraz zastosuj Traefik Middleware - wymaga istniejącego namespace (dlatego nie robiliśmy tego w sekcji 4):

APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh k8s/traefik/middleware.yaml | kubectl apply -f -

7.2 Sekret MySQL

bootstrap.sh tworzy ten sekret automatycznie. Jeśli konfigurujesz klaster ręcznie, utwórz go z CLI:

kubectl create secret generic app-mysql \
  --from-literal=root-password='SuperTajneHasloRoot123!' \
  --from-literal=username='app' \
  --from-literal=password='SuperTajneHasloApp456!' \
  --namespace=app \
  --dry-run=client -o yaml | kubectl apply -f -

Jeśli używasz innego prefixu/namespace, zamień app-mysql na ${APP_NAME}-mysql, a --namespace=app na --namespace=${KUBE_NAMESPACE}.

7.3 Sekret Redis

kubectl create secret generic app-redis \
  --from-literal=password='SuperTajneHasloRedis789!' \
  --namespace=app \
  --dry-run=client -o yaml | kubectl apply -f -

7.4 Sekret aplikacji Laravel - opcjonalne przy CI/CD

Jeśli używasz GitHub Actions lub GitLab CI/CD - pomiń ten krok.
GitHub Actions automatycznie tworzy i aktualizuje ten sekret ze zmiennej PROD_ENV przy każdym deploymencie (krok 0 w pipeline). GitLab jako alternatywa używa SERVER_ENV. Ręczne tworzenie jest potrzebne tylko jeśli chcesz uruchomić aplikację przed pierwszym CI/CD.

Jeśli jednak chcesz skonfigurować sekret ręcznie (np. przed pierwszym CI/CD run), użyj szablonu server/.env.production.example:

# Uzupełnij server/.env.production.example swoimi wartościami, a następnie:
kubectl create secret generic app-server-env \
  --from-file=.env=server/.env.production \
  --namespace=app \
  --dry-run=client -o yaml | kubectl apply -f -

Skąd wziąć APP_KEY?
docker compose exec php php artisan key:generate --show

7.5 Konfiguracja klienta Next.js - brak sekretu

Next.js po stronie serwera fetchuje dane z API pod wewnętrznym adresem klastra. Domyślnie jest to API_URL=http://app-server.app.svc.cluster.local/api/v1; po zmianie konfiguracji renderer ustawia http://${APP_NAME}-server.${KUBE_NAMESPACE}.svc.cluster.local/api/v1. Ten adres nie jest wrażliwy - to po prostu nazwa DNS wewnątrz klastra - więc nie ma tu żadnego sekretu do utworzenia.

API_URL jest zwykłą zmienną env: w k8s/client/deployment.yaml i jest renderowany przez k8s/render.sh. Nic nie musisz robić ręcznie.

Zmienne NEXT_PUBLIC_* (publiczne, widoczne w przeglądarce) to osobna sprawa - są wstrzykiwane przy buildzie obrazu jako --build-arg z ENV_CLIENT_PROD (patrz sekcja 14).


8. Wdrożenie bazy danych i cache (MySQL + Redis)

8.1 MySQL

APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh \
  k8s/mysql/statefulset.yaml \
  k8s/mysql/service.yaml | kubectl apply -f -

Poczekaj, aż MySQL będzie gotowy:

kubectl -n app get pods --watch
# NAME           READY   STATUS    RESTARTS   AGE
# app-mysql-0    1/1     Running   0          2m

Dlaczego StatefulSet, a nie Deployment?
StatefulSet gwarantuje stabilną nazwę poda (app-mysql-0) i kolejność uruchamiania. Dla baz danych to ważne - dzięki temu DNS app-mysql-0.app-mysql.app.svc.cluster.local zawsze wskazuje na tę samą instancję.

Sprawdź czy MySQL działa:

kubectl -n app exec -it app-mysql-0 -- mysql -u root -p
# Enter password: <root-password z sekretu>
# mysql> SHOW DATABASES;

8.2 Redis

APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh \
  k8s/redis/pvc.yaml \
  k8s/redis/deployment.yaml \
  k8s/redis/service.yaml | kubectl apply -f -

kubectl -n app get pods | grep redis
# app-redis-xxxxxxxxx-xxxxx   1/1   Running   0   1m

Sprawdź połączenie:

kubectl -n app exec -it deployment/app-redis -- redis-cli -a <REDIS_PASSWORD> ping
# PONG

9. Wdrożenie Gotenberg (PDF)

Gotenberg to mikroserwis do generowania PDF z HTML. Wdrażamy go jako oddzielny pod, dostępny tylko wewnątrz klastra.

APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh \
  k8s/gotenberg/deployment.yaml \
  k8s/gotenberg/service.yaml | kubectl apply -f -

kubectl -n app get pods | grep gotenberg
# app-gotenberg-xxxxxxxxx-xxxxx   1/1   Running   0   30s

Typesense to szybki silnik full-text search. Aplikacja używa go przez Laravel Scout (SCOUT_DRIVER=typesense). Wdrażamy go jako osobny pod dostępny tylko wewnątrz klastra.

10.1 Utwórz sekret z kluczem API

Typesense wymaga klucza API do autoryzacji zapytań. Użyj losowego, silnego klucza (min. 16 znaków):

kubectl create secret generic app-typesense \
  --from-literal=api-key=<TWÓJ_KLUCZ_API> \
  --namespace=app \
  --dry-run=client -o yaml | kubectl apply -f -

Ten sam klucz wpisz w PROD_ENV jako TYPESENSE_API_KEY=<TWÓJ_KLUCZ_API>.
Ważne: Nie dodawaj komentarzy (#) za wartością - Laravel odczyta je jako część klucza.

10.2 Wdróż Typesense

APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh \
  k8s/typesense/pvc.yaml \
  k8s/typesense/deployment.yaml \
  k8s/typesense/service.yaml | kubectl apply -f -

Poczekaj na uruchomienie (~20 sekund inicjalizacji):

kubectl -n app rollout status deployment/app-typesense --timeout=120s
# deployment "app-typesense" successfully rolled out

Sprawdź health check:

kubectl -n app exec deployment/app-typesense -- wget -qO- http://localhost:8108/health
# {"ok":true}

10.3 Konfiguracja w PROD_ENV

Upewnij się, że w PROD_ENV (zmienna CI/CD) masz:

SCOUT_DRIVER=typesense
SCOUT_QUEUE=true
TYPESENSE_HOST=app-typesense.app.svc.cluster.local
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_API_KEY=<TWÓJ_KLUCZ_API>

10.4 Zaindeksuj dane po pierwszym wdrożeniu

Po pierwszym deployu zaimportuj istniejące dane do indeksów:

kubectl -n app exec deployment/app-server -- \
  php artisan scout:import "App\Models\Product"

kubectl -n app exec deployment/app-server -- \
  php artisan scout:import "App\Models\BlogPost"

Scout automatycznie indeksuje nowe i zmienione rekordy przez kolejkę (Redis) - import ręczny jest potrzebny tylko raz, przy pierwszym wdrożeniu.


11. Pull Secret dla Container Registry (GHCR / GitLab)

Twój klaster musi wiedzieć, jak pobierać prywatne obrazy Docker. Konfiguracja zależy od tego, jakiego CI/CD używasz.

Ważne: Nazwa sekretu ghcr-pull-secret jest taka sama w obu przypadkach - manifesty deploymentów (k8s/server/deployment.yaml, k8s/client/deployment.yaml) już jej używają. Nie zmieniaj tej nazwy.

11.1 GitHub Container Registry (GHCR)

Jeśli używasz GitHub Actions i budujesz obrazy do ghcr.io:

Utwórz Personal Access Token

W GitHub: Settings → Developer settings → Personal access tokens → Tokens (classic)

  • Scopes: read:packages
  • Kliknij Generate token i zapisz - zobaczysz go tylko raz

Utwórz sekret w klastrze

kubectl create secret docker-registry ghcr-pull-secret \
  --docker-server=ghcr.io \
  --docker-username=<TWÓJ_GITHUB_USERNAME> \
  --docker-password=<PERSONAL_ACCESS_TOKEN> \
  --namespace=app

11.2 GitLab Container Registry

Jeśli używasz GitLab CI i budujesz obrazy do registry.gitlab.com:

Utwórz Deploy Token w GitLab

W GitLab: Settings → Repository → Deploy tokens → New deploy token

  • Name: k3s-pull
  • Scopes: zaznacz read_registry
  • Kliknij Create deploy token
  • Zapisz username i token - zobaczysz je tylko raz!

Utwórz sekret w klastrze

kubectl create secret docker-registry ghcr-pull-secret \
  --docker-server=registry.gitlab.com \
  --docker-username=<deploy-token-username> \
  --docker-password=<deploy-token-password> \
  --namespace=app

12. Wdrożenie aplikacji (serwer + klient)

Przed pierwszym deployem przez CI/CD musimy ręcznie zastosować manifesty infrastruktury. Obrazy Docker będą budowane przez pipeline - na razie używamy :latest (po pierwszym CI pushu).

12.1 Persistent storage dla uploadsów i logów

Zanim uruchomisz serwer, utwórz PVC (PersistentVolumeClaim) - wolumen na dysku VPS, który przechowa uploadowane pliki i logi Laravel.

APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh k8s/server/pvc-storage.yaml | kubectl apply -f -

Sprawdź że PVC jest gotowe:

kubectl -n app get pvc
# NAME                 STATUS   VOLUME         CAPACITY   STORAGECLASS
# app-server-storage   Bound    pvc-xxxxxxxx   20Gi       local-path

STATUS: Bound oznacza że wolumen jest gotowy. local-path to wbudowany w k3s mechanizm przechowywania danych na lokalnym dysku VPS.

Co przechowuje PVC?

PVC (20Gi na dysku VPS)
  ├── storage/app/                   ← uploadowane pliki (zdjęcia, PDF-y, załączniki)
  ├── storage/logs/                  ← logi Laravel (gdy LOG_CHANNEL=daily lub stack)
  └── storage/framework/             ← cache widoków, sesje, kolejki, eksporty Excela
      ├── cache/data/                  ← cache aplikacji (gdy CACHE_STORE=file)
      ├── cache/laravel-excel/         ← tymczasowe pliki dla queued Excel exports
      ├── sessions/                    ← sesje (gdy SESSION_DRIVER=file)
      └── views/                       ← skompilowane Blade-y

Dane przeżywają restarty podów i deploye. Giną tylko jeśli ręcznie usuniesz PVC.

Ważne: Katalogi storage/framework/* muszą istnieć zanim uruchomi się pod - inaczej:

  • php artisan view:cache wywala się przy starcie
  • queued Excel exports padają z fopen(... laravel-excel/...): No such file or directory
  • sesje filesowe fail-ują

Jeśli Twój Dockerfile nie tworzy tych katalogów w obrazie, dodaj je do command/entrypoint poda lub do migration joba:

mkdir -p storage/framework/{cache/data,cache/laravel-excel,sessions,views} \
         storage/app/{public,private} storage/logs \
  && chown -R www-data:www-data storage bootstrap/cache

Najlepsza praktyka: dodaj tę linię do Dockerfile (warstwa zaraz przed USER www-data) - wtedy każdy nowy obraz ma poprawną strukturę.

W pliku .env produkcyjnym ustaw:

FILESYSTEM_DISK=public   # pliki na lokalny dysk (przez PVC)
LOG_CHANNEL=stderr       # logi do kubectl logs (najwygodniejsze w k8s)
# LOG_CHANNEL=stack      # lub oba: stderr + plik
# LOG_STACK=stderr,daily

Ograniczenie PVC: działa tylko przy replicas: 1. Przy skalowaniu do 2+ podów przejdź na MinIO - patrz sekcja z bonusami na końcu przewodnika.

12.2 Wdrożenie serwerów

# Serwer (Laravel)
APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh \
  k8s/server/service.yaml \
  k8s/server/deployment.yaml \
  k8s/server/deployment-queue.yaml \
  k8s/server/cronjob-scheduler.yaml \
  k8s/server/hpa.yaml | kubectl apply -f -

# Klient (Next.js)
APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh \
  k8s/client/service.yaml \
  k8s/client/deployment.yaml \
  k8s/client/hpa.yaml | kubectl apply -f -

# Ingress (routing + TLS)
APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh k8s/ingress.yaml | kubectl apply -f -

Sprawdź status:

kubectl -n app get all

Powinieneś zobaczyć:

NAME                               READY   STATUS
pod/app-server-xxxxxxxxx-xxxxx     1/1     Running
pod/app-queue-xxxxxxxxx-xxxxx      1/1     Running
pod/app-client-xxxxxxxxx-xxxxx     1/1     Running
pod/app-mysql-0                    1/1     Running
pod/app-redis-xxxxxxxxx-xxxxx      1/1     Running
pod/app-gotenberg-xxxxxxxxx-xxxxx  1/1     Running

NAME                TYPE        CLUSTER-IP
service/app-server  ClusterIP   10.43.x.x
service/app-client  ClusterIP   10.43.x.x
service/app-mysql   ClusterIP   None
service/app-redis   ClusterIP   10.43.x.x
service/app-gotenberg ClusterIP 10.43.x.x

12.3 Queue worker (joby w tle)

Queue worker to osobny Deployment (app-queue), który nieprzerwanie czyta kolejkę Redis i wykonuje joby z aplikacji. Bez niego:

  • maile, notyfikacje SSE, indeksacja Scout → wszystko leci do kolejki i nikt tego nie przetworzy
  • konwersje obrazów Spatie MediaLibrary → uploadowane zdjęcia nie dostają thumbów
  • queued Excel exports → użytkownik nigdy nie dostaje pliku
  • powiadomienia e-mail po zakupie / rejestracji → nigdy nie wychodzą

Manifest k8s/server/deployment-queue.yaml startuje 2 repliki (HA) i uruchamia:

php artisan queue:work --tries=3 --sleep=3 --max-time=3600 --no-ansi

--max-time=3600 powoduje, że worker sam się restartuje co godzinę - to chroni przed wyciekami pamięci w długich procesach.

Sprawdź że workery działają:

kubectl -n app get pods -l component=queue
# NAME                       READY   STATUS    RESTARTS   AGE
# app-queue-xxxxx-aaaaa      1/1     Running   0          12m
# app-queue-xxxxx-bbbbb      1/1     Running   0          12m

kubectl -n app logs deployment/app-queue --tail=30

Failed jobs - co z nimi robić:

Joby, które wywaliły się 3 razy, trafiają do tabeli failed_jobs. Sprawdź je regularnie:

# Lista
kubectl -n app exec deployment/app-server -- php artisan queue:failed

# Szczegóły konkretnego joba (exception)
kubectl -n app exec deployment/app-server -- \
  php artisan queue:failed | grep "ProductsExport"

# Retry pojedynczego joba
kubectl -n app exec deployment/app-server -- \
  php artisan queue:retry <UUID>

# Retry wszystkich
kubectl -n app exec deployment/app-server -- php artisan queue:retry all

# Wywal wszystkie failed jobs (czyszczenie)
kubectl -n app exec deployment/app-server -- php artisan queue:flush

Skalowanie: Jeśli kolejka rośnie szybciej niż workerzy ją przetwarzają, zwiększ repliki:

kubectl -n app scale deployment/app-queue --replicas=4

Pamiętaj że każdy worker pobiera ten sam .env - duża ilość workerów = większe zużycie pamięci i połączeń do Redis/MySQL.

12.4 Scheduler (cron joby Laravel)

Laravel ma wbudowany scheduler - w routes/console.php definiujesz zadania, które mają chodzić cyklicznie (np. publikacja zaplanowanych postów, czyszczenie koszyków). Lista zadań:

kubectl -n app exec deployment/app-server -- php artisan schedule:list

W tradycyjnym serwerze ustawiasz w crontab jeden wpis:

* * * * * php artisan schedule:run >> /dev/null 2>&1

W k3s odpowiednikiem tego jest CronJob (k8s/server/cronjob-scheduler.yaml) - Kubernetes co minutę startuje krótkotrwały pod, który odpala php artisan schedule:run, i go zabija.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: app-scheduler
spec:
  schedule: "* * * * *"          # co minutę
  concurrencyPolicy: Forbid       # nie startuj nowego, jeśli poprzedni jeszcze leci
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: scheduler
              image: ghcr.io/<user>/app-server:latest
              command: ["php", "artisan", "schedule:run"]

Weryfikacja:

# CronJob istnieje
kubectl -n app get cronjob app-scheduler
# NAME            SCHEDULE    LAST SCHEDULE   AGE
# app-scheduler   * * * * *   39s             1d

# Ostatnie kilka wywołań (pody z statusem Completed)
kubectl -n app get pods | grep app-scheduler | tail -5

# Logi z ostatniego runa
LAST=$(kubectl -n app get pods -o name | grep scheduler | tail -1)
kubectl -n app logs $LAST
# Running ['artisan' blog:publish-scheduled] . 2 sek. DONE
# Running ['artisan' app:process-scheduled-pages]  2 sek. DONE

Częsta pomyłka: CronJob używa tego samego obrazu co app-server. Pipeline GitHub Actions renderuje i aplikuje k8s/server/cronjob-scheduler.yaml z IMAGE_SERVER=<sha-tag>, więc scheduler dostaje ten sam obraz co web i queue.

12.5 Mail (SMTP)

Laravel wysyła maile transactional przez SMTP. W k3s masz dwie opcje:

Opcja A - zewnętrzny SMTP (rekomendowane na produkcję)

Mailgun, SendGrid, Resend, Postmark, Amazon SES, własny SMTP. Wpisz dane w PROD_ENV:

MAIL_MAILER=smtp
MAIL_HOST=smtp.resend.com         # lub smtp.mailgun.org, smtp.eu.mailgun.org, ...
MAIL_PORT=587
MAIL_USERNAME=resend
MAIL_PASSWORD=re_xxxxxxxxxxxxxxxx
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"

Najczęstsza pomyłka: MAIL_HOST=smtp.yourdomain.com jako placeholder - wszystkie maile fail-ują z getaddrinfo failed. Sprawdź realny config:

kubectl -n app exec deployment/app-server -- \
  php artisan config:show mail.mailers.smtp.host

Opcja B - mailpit na klastrze (do testów / staging)

Mailpit to lekki SMTP server z webowym UI - łapie wszystkie maile w pamięci i pokazuje je w przeglądarce, nic nie wychodzi na zewnątrz. Idealny do staging i developerskich smoke-testów.

Zapisz manifest k8s/mailpit/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-mailpit
  namespace: app
spec:
  replicas: 1
  selector: { matchLabels: { app: app-mailpit } }
  template:
    metadata: { labels: { app: app-mailpit } }
    spec:
      containers:
        - name: mailpit
          image: axllent/mailpit:latest
          ports:
            - { name: smtp, containerPort: 1025 }
            - { name: http, containerPort: 8025 }
          resources:
            requests: { cpu: 10m, memory: 32Mi }
            limits:   { cpu: 100m, memory: 128Mi }
---
apiVersion: v1
kind: Service
metadata: { name: mailpit, namespace: app }
spec:
  selector: { app: app-mailpit }
  ports:
    - { name: smtp, port: 1025, targetPort: 1025 }
    - { name: http, port: 8025, targetPort: 8025 }
kubectl apply -f k8s/mailpit/deployment.yaml

W PROD_ENV (lub STAGING_ENV) ustaw:

MAIL_MAILER=smtp
MAIL_HOST=mailpit            # usługa DNS w klastrze
MAIL_PORT=1025
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="[email protected]"

Webowe UI (port 8025) wyeksponuj przez Ingress albo kubectl port-forward:

kubectl -n app port-forward svc/mailpit 8025:8025
# otwórz http://localhost:8025

Test wysyłki:

kubectl -n app exec deployment/app-server -- \
  php artisan tinker --execute='Mail::raw("test ".now(), fn($m) => $m->to("[email protected]")->subject("ping"));'

12.6 Reverb / broadcasting (realtime - chat, notyfikacje)

Jeśli aplikacja używa WebSocket-ów (Laravel Reverb, Pusher, Soketi) - np. live chat support, push notyfikacji w panelu admina - potrzebujesz osobnego deploya.

Najprościej z Laravel Reverb (oficjalny, dołączony do Laravel ≥11):

# k8s/reverb/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: app-reverb, namespace: app }
spec:
  replicas: 1                     # Reverb trzyma WS in-memory; HA wymaga zewnętrznego state (Redis pub/sub działa, ale klienci są sticky)
  selector: { matchLabels: { app: app-reverb } }
  template:
    metadata: { labels: { app: app-reverb } }
    spec:
      imagePullSecrets: [{ name: ghcr-pull-secret }]
      containers:
        - name: reverb
          image: ghcr.io/<user>/app-server:latest
          command: ["php", "artisan", "reverb:start", "--host=0.0.0.0", "--port=8080"]
          envFrom: [{ secretRef: { name: app-server-env } }]
          ports: [{ containerPort: 8080 }]
---
apiVersion: v1
kind: Service
metadata: { name: app-reverb, namespace: app }
spec:
  selector: { app: app-reverb }
  ports: [{ port: 8080, targetPort: 8080 }]

Wymagane zmienne w PROD_ENV:

BROADCAST_CONNECTION=reverb
REVERB_APP_ID=<losowy_id>
REVERB_APP_KEY=<losowy_key>
REVERB_APP_SECRET=<losowy_secret>
REVERB_HOST=app-reverb           # serwerowo, w klastrze
REVERB_PORT=8080
REVERB_SCHEME=http

# Frontend / build args klienta:
NEXT_PUBLIC_REVERB_APP_KEY=<ten sam key>
NEXT_PUBLIC_REVERB_HOST=ws.yourdomain.com   # publiczny host, przez Ingress
NEXT_PUBLIC_REVERB_PORT=443
NEXT_PUBLIC_REVERB_SCHEME=https

W Ingressie dodaj host ws.yourdomain.com ze ścieżką /appapp-reverb:8080. Traefik z definicji obsługuje WebSocket upgrade - nic dodatkowo nie ustawiasz.

Bez Reverba: w .env zostaw BROADCAST_CONNECTION=log (eventy będą tylko logowane) lub redis (eventy lecą do Redis pub/sub, ale frontend nie ma jak ich odebrać). Chat / SSE notyfikacje będą działać tylko przez polling.

12.7 Laravel Excel - gotchas

Eksporty Maatwebsite\Excel w tym projekcie (ProductsExport, OrdersExport, CustomersExport, CustomReportExport) implementują ShouldQueue - to znaczy że Excel::store(...) natychmiast wraca, a faktyczny zapis pliku dzieje się w workerze.

Konsekwencje:

  1. Wymagany queue worker - bez app-queue Running eksporty nigdy nie powstaną.
  2. Wymagany katalog cache - worker pisze pliki tymczasowe do storage/framework/cache/laravel-excel/. Jeśli katalog nie istnieje, queue job fail-uje z:
    ErrorException: fopen(.../storage/framework/cache/laravel-excel/laravel-excel-XXX.xlsx): No such file or directory
    
    Patrz "Co przechowuje PVC?" wyżej - mkdir -p storage/framework/cache/laravel-excel.
  3. Disk = local w config/excel.php celuje w storage/app (lub storage/app/private od Laravel 11). Jeśli chcesz że pliki przeżyją restart poda - to PVC obsługuje, jeśli replicas: 1. Przy 2+ replikach przerzuć eksporty na MinIO/S3 (Excel::store(..., 's3')).

12.8 Testowanie bez domeny (sslip.io)

Jeśli nie masz jeszcze prawdziwej domeny, użyj ingressu deweloperskiego - tylko HTTP, bez TLS:

# Edytuj k8s/ingress-dev.yaml i zamień 1.2.3.4 na IP swojego serwera
kubectl apply -f k8s/ingress-dev.yaml

Usługa sslip.io automatycznie rozwiązuje subdomeny:

  • app.1.2.3.4.sslip.io → IP Twojego serwera (frontend Next.js)
  • api.1.2.3.4.sslip.io → IP Twojego serwera (admin Laravel)

Przełącz na ingress produkcyjny, gdy domena będzie gotowa:

kubectl delete -f k8s/ingress-dev.yaml
kubectl apply -f k8s/ingress.yaml

13. Jak CI/CD łączy się z k3s?

Zanim przejdziesz do konfiguracji, warto zrozumieć jak pipeline w ogóle może sterować Kubernetes na Twoim serwerze.

Tradycyjny deploy (SSH)

W klasycznym podejściu (np. Deployer, Capistrano) pipeline loguje się na serwer przez SSH i uruchamia skrypty:

GitHub/GitLab Actions → SSH (port 22) → VPS
                          ↓
                      scp pliki
                      ./deploy.sh

Ty zarządzasz tym, jak kod trafia na serwer.

Deploy przez Kubernetes API (kubectl)

W podejściu z Kubernetes pipeline nie wysyła kodu - kod jest już w obrazie Docker (zbudowanym i wepchniętym do rejestru). Pipeline mówi tylko k8s: „użyj teraz tego obrazu":

GitHub/GitLab Actions → HTTPS (port 6443) → k8s API → k3s
                                                 ↓
                                    "zmień obraz w deployment na :abc1234"
                                    k8s sam robi rolling update

Co to jest KUBECONFIG?

kubectl to klient HTTP - łączy się z API serwera k3s przez HTTPS na porcie 6443. Cały kontekst połączenia (adres serwera + poświadczenia) jest w pliku kubeconfig:

apiVersion: v1
clusters:
- cluster:
    server: https://TWOJE_VPS_IP:6443   # ← adres serwera
    certificate-authority-data: BASE64  # ← cert CA (weryfikacja serwera)
  name: default
users:
- user:
    client-certificate-data: BASE64     # ← Twój "klucz" (jak SSH key)
    client-key-data: BASE64
  name: default

Cały plik kubeconfig wklejasz jako jeden sekret w CI/CD - zastępuje SSH_HOST + SSH_PORT + SSH_USER + SSH_KEY razem.

Tradycyjny SSHKubernetes
SSH_HOST✅ zawarte w kubeconfig
SSH_PORT✅ zawarte w kubeconfig (6443)
SSH_USER✅ zawarte w kubeconfig (client cert)
SSH_KEY✅ zawarte w kubeconfig (client key)

Jak uzyskać kubeconfig?

Na lokalnym komputerze (masz już skonfigurowany kubectl z sekcji 4.2):

cat ~/.kube/config-hetzner

Skopiuj cały wynik (zaczyna się od apiVersion: v1) i wklej jako wartość sekretu KUBECONFIG_PROD (GitHub) lub KUBECONFIG (GitLab). GitHub i GitLab obsługują wieloliniowe wartości - nie koduj do base64.

Upewnij się, że kubeconfig wskazuje na publiczny IP serwera (nie 127.0.0.1). Jeśli skopiowałeś go komendą z sekcji 4.2 (z sed), jest już poprawny.

Co musisz otworzyć na serwerze?

# Firewall Hetzner (panel Cloud) lub UFW - otwórz port 6443
ufw allow 6443/tcp comment "k8s API for CI/CD"

Port 22 (SSH) możesz zostawić tylko dla siebie - CI/CD już go nie potrzebuje.


14. Konfiguracja GitHub Actions

Repozytorium zawiera plik .github/workflows/deploy.yml z główną ścieżką deploymentu. Musisz tylko skonfigurować zmienne w GitHub.

14.1 Zmienne i sekrety

W GitHub: Settings → Secrets and Actions → Secrets / Variables

Secrets (write-only, maskowane w logach)

SecretOpis
KUBECONFIG_PRODSurowa treść kubeconfig - patrz sekcja 13
# Jak uzyskać wartość (bez base64):
cat ~/.kube/config-hetzner

Variables (widoczne i edytowalne w UI)

VariablePrzykładOpis
APP_NAMEappPrefiks zasobów i nazw obrazów; domyślnie app
KUBE_NAMESPACEappNamespace Kubernetes; domyślnie APP_NAME
PROD_ENV(pełna treść .env produkcyjnego)Automatycznie sync do k8s Secret
ENV_CLIENT_PROD(pełna treść .env frontendowego)Build-time zmienne dla Next.js (multiline)

Dlaczego PROD_ENV jako Variable, a nie Secret?
Secrets są write-only - raz wklejonego nie możesz odczytać ani edytować linijka po linijce. Variables są widoczne w UI, więc łatwo sprawdzisz co jest ustawione. Pipeline i tak sync'uje wartość do k8s Secret przed deployem.

PROD_ENV zostaje GitHub Actions Variable zgodnie z decyzją projektu. Repozytorium jest prywatne; nie przenoś tej wartości do secrets.PROD_ENV.

Jak ustawić PROD_ENV

Treść to dosłownie zawartość Twojego server/.env.production:

APP_NAME="Myapp"
APP_ENV=production
APP_KEY=base64:...
APP_DEBUG=false
APP_URL=https://admin.yourdomain.com
FRONTEND_URL=https://yourdomain.com

DB_HOST=app-mysql.app.svc.cluster.local
DB_PASSWORD=SuperTajneHaslo456!

REDIS_HOST=app-redis.app.svc.cluster.local
REDIS_PASSWORD=SuperTajneHasloRedis789!

GOTENBERG_URL=http://app-gotenberg.app.svc.cluster.local:3000
...

GitHub obsługuje wieloliniowe Variables - wklej całą treść.

Jak ustawić ENV_CLIENT_PROD

Treść to .env dla frontendu Next.js - tylko zmienne build-time potrzebne przy budowaniu obrazu:

NEXT_PUBLIC_API_URL=https://admin.yourdomain.com
NEXT_PUBLIC_APP_NAME=Myapp

Dodaj tyle zmiennych NEXT_PUBLIC_* ile potrzebujesz. Pipeline automatycznie przekaże je wszystkie do docker buildx build jako wpisy --build-arg.

14.2 Jak działa pipeline

push → master/main
        │
        ├── lint-server    (Pint, Rector, PHPStan, ESLint, Prettier)
        ├── lint-client    (TypeScript, ESLint, Prettier)
        ├── security       (composer audit, npm audit)
        ├── test           (Pest PHP - matrix 8.4 + 8.5)
        │
        ├── build-server   → ghcr.io/<owner>/<APP_NAME>-server:abc1234
        ├── build-client   → ghcr.io/<owner>/<APP_NAME>-client:abc1234
        │
        └── deploy
              ├── 0. render namespace/config + create <APP_NAME>-server-env (sync PROD_ENV)
              ├── 1. render job-migrate (migracje DB, wait 2 min)
              ├── 2. render deployment/<APP_NAME>-server + rollout restart (odświeża .env z Secret)
              ├── 3. render deployment/<APP_NAME>-queue + rollout restart (odświeża .env z Secret)
              ├── 4. render cronjob/<APP_NAME>-scheduler
              └── 5. render deployment/<APP_NAME>-client

Każdy deploy:

  1. Najpierw aktualizuje k8s Secret z PROD_ENV - migracje dostają świeży .env, bo startują jako nowy Job
  2. Uruchamia php artisan migrate --force jako jednorazowy Job z nowym obrazem
  3. Czeka na jego zakończenie (max 2 minuty)
  4. Dopiero potem robi rolling update deploymentów
  5. Jawnie restartuje app-server i app-queue, bo .env jest montowany przez subPath i zmiany w Secret są widoczne dopiero po odtworzeniu poda

Dzięki temu migracje zawsze są przed nowym kodem - żadnych „column does not exist".

14.3 Obrazy bez serwera rejestru

GitHub Container Registry (GHCR) jest wbudowany w GitHub - nie musisz konfigurować żadnego zewnętrznego rejestru. Pipeline automatycznie loguje się z GITHUB_TOKEN (dostępny automatycznie w każdym workflow).

14.4 GitHub Environments i ochrona deploymentu

Job deploy używa environment: production. Reguły ochrony skonfigurujesz w Settings → Environments → production:

  • Wymagani recenzenci przed deployem
  • Wait timer (np. 5-minutowe opóźnienie)
  • Whitelist gałęzi deploymentu

Job używa też grupy concurrency (production-deploy) z cancel-in-progress: false - trwający deploy nigdy nie jest anulowany przez kolejny push.


15. Konfiguracja GitLab CI/CD

Repozytorium zawiera plik .gitlab-ci.yml jako alternatywną ścieżkę deploymentu. Główną ścieżką projektu jest GitHub Actions.

15.1 Zmienne CI/CD

W GitLab: Settings → CI/CD → Variables → Add variable

Wymagane zmienne

ZmiennaTypMaskedProtectedOpis
APP_NAMEVariablePrefiks zasobów i obrazów; domyślnie app
KUBE_NAMESPACEVariableNamespace; domyślnie APP_NAME
KUBECONFIGVariableSurowa treść kubeconfig - patrz sekcja 13
SERVER_ENVVariablePełna treść server/.env.production
ENV_CLIENT_PRODVariableBuild-time zmienne dla Next.js (multiline)

GitLab pipeline ma też domyślne SERVER_IMAGE=$CI_REGISTRY_IMAGE/$APP_NAME-server i CLIENT_IMAGE=$CI_REGISTRY_IMAGE/$APP_NAME-client. Nadpisuj je tylko, jeśli świadomie używasz innego rejestru.

Nie musisz ustawiać zmiennych rejestru - GitLab dostarcza je automatycznie:

  • CI_REGISTRY - adres rejestru
  • CI_REGISTRY_USER - użytkownik
  • CI_REGISTRY_PASSWORD - hasło

Jak uzyskać KUBECONFIG

cat ~/.kube/config-hetzner

Skopiuj cały wynik i wklej jako wartość zmiennej KUBECONFIG w GitLab. Nie koduj do base64 - GitLab obsługuje wieloliniowe wartości.

Jak uzyskać SERVER_ENV

Treść zmiennej SERVER_ENV to dosłownie zawartość twojego server/.env.production:

APP_NAME="Myapp"
APP_ENV=production
APP_KEY=base64:...
APP_DEBUG=false
APP_URL=https://admin.yourdomain.com
FRONTEND_URL=https://yourdomain.com
...

Wklej całą treść jako wartość zmiennej (GitLab obsługuje wieloliniowe zmienne).

15.2 Jak działa pipeline

push → master
        │
        ├── lint-server    (Pint, Rector, PHPStan, ESLint, Prettier)
        ├── lint-client    (TypeScript, ESLint, Prettier)
        ├── security       (composer audit, npm audit)
        ├── test           (Pest PHP)
        │
        ├── build-server   → registry.gitlab.com/.../<APP_NAME>-server:abc1234
        ├── build-client   → registry.gitlab.com/.../<APP_NAME>-client:abc1234
        │
        └── deploy
              ├── 0. render namespace/config + sync SERVER_ENV
              ├── 1. render job-migrate (migracje DB)
              ├── 2. render deployment/<APP_NAME>-server + rollout restart
              ├── 3. render deployment/<APP_NAME>-queue + rollout restart
              ├── 4. render cronjob/<APP_NAME>-scheduler
              └── 5. render deployment/<APP_NAME>-client

16. Pierwsze wdrożenie przez CI

Zrób push do gałęzi master:

git add .github/ k8s/
git commit -m "ci: add CI/CD pipeline and k3s manifests"
git push origin master

GitHub Actions: Przejdź do Actions w repozytorium i obserwuj pipeline.

GitLab CI: Przejdź do CI/CD → Pipelines i obserwuj pipeline.

Pierwsze uruchomienie trwa dłużej (~10-15 min) bo:

  • pierwszy raz wypełnia cache Composer/npm/GitHub Actions
  • pierwszy raz buduje obrazy Docker i zapisuje warstwy BuildKit

Kolejne pipelines są szybsze dzięki cache Composer/npm oraz osobnym cache BuildKit dla obrazów server/client.

Sprawdź wyniki

Po zakończeniu pipeline'u:

kubectl -n app get pods
kubectl -n app get ingress

Sprawdź certyfikat TLS:

kubectl -n app describe certificate app-tls
# Status: True (Ready)

Wejdź w przeglądarkę:

  • https://yourdomain.com - frontend Next.js
  • https://admin.yourdomain.com/health - health check Laravel (powinien zwrócić {"status":"ok"})

17. Weryfikacja - czy wszystko działa?

Checklist po pierwszym wdrożeniu

# Wszystkie pody Running
kubectl -n app get pods

# Certyfikat TLS wystawiony
kubectl -n app get certificate
# NAME      READY   SECRET    AGE
# app-tls   True    app-tls   5m

# Ingress ma adres IP
kubectl -n app get ingress
# NAME          CLASS     HOSTS              ADDRESS       PORTS
# app-ingress   traefik   yourdomain.com...  <IP>          80, 443

# Laravel odpowiada
curl -s https://admin.yourdomain.com/health
# {"status":"ok","timestamp":"..."}

# Frontend odpowiada
curl -I https://yourdomain.com
# HTTP/2 200

# Migracje się wykonały
kubectl -n app exec -it deployment/app-server -- \
  php artisan migrate:status | tail -5

# Queue workers działają
kubectl -n app logs deployment/app-queue --tail=20

Health probes

Deployment serwera ma dwa probe'y:

  • Liveness (/healthz): poziom nginx - Kubernetes restartuje poda, jeśli proces jest martwy
  • Readiness (/health): endpoint health Laravela - Kubernetes nie kieruje ruchu, dopóki aplikacja nie jest gotowa

Test uploadów

Zaloguj się do panelu admina i spróbuj wgrać obraz - weryfikuje MySQL, storage (S3/R2) i nginx (body size limit).


18. Codzienna obsługa - logi, restarty, aktualizacje

Podgląd logów

# Logi konkretnego poda (live)
kubectl -n app logs -f deployment/app-server

# Logi queue workers
kubectl -n app logs -f deployment/app-queue

# Logi kilku ostatnich podów (po restarcie)
kubectl -n app logs deployment/app-server --previous

# Logi z ostatnich 1 godziny
kubectl -n app logs deployment/app-server --since=1h

Restart poda / deploymentu

# Restart deployment (tworzy nowe pody rolling)
kubectl -n app rollout restart deployment/app-server

# Wymuszony restart konkretnego poda (Kubernetes zastąpi go nowym)
kubectl -n app delete pod <nazwa-poda>

Wejście do kontenera (jak docker exec)

kubectl -n app exec -it deployment/app-server -- bash

# Wewnątrz:
php artisan tinker
php artisan cache:clear
php artisan queue:restart

Sprawdzenie zasobów (CPU / RAM)

kubectl -n app top pods
# NAME                        CPU(cores)   MEMORY(bytes)
# app-server-xxx              45m          210Mi
# app-queue-xxx               12m          128Mi
# app-client-xxx              8m           95Mi
# app-mysql-0                 35m          480Mi
# app-redis-xxx               3m           28Mi

Rollback deploymentu

Ważne: rollback deploymentu nie cofa migracji bazy danych. Migracje produkcyjne muszą być backward-compatible i prowadzone stylem expand-contract: najpierw dodaj nowe kolumny/struktury, wdroż kod kompatybilny wstecz, dopiero w osobnym deployu usuń stare elementy.

Jeśli nowa wersja psuje coś krytycznego:

# Sprawdź historię
kubectl -n app rollout history deployment/app-server

# Rollback do poprzedniej wersji
kubectl -n app rollout undo deployment/app-server

# Rollback do konkretnej wersji
kubectl -n app rollout undo deployment/app-server --to-revision=2

Aktualizacja k3s

# Na serwerze
curl -sfL https://get.k3s.io | sh -
# k3s sam wykryje istniejącą instalację i zaktualizuje się

19. Backup MySQL

MySQL działa na PersistentVolume - dane są na dysku serwera. Nie polegaj jednak wyłącznie na tym!

Ręczny backup

kubectl -n app exec app-mysql-0 -- \
  mysqldump -u root -p<ROOT_PASSWORD> app \
  > backup_$(date +%Y%m%d_%H%M%S).sql

Automatyczny backup przez CronJob

Repozytorium zawiera gotowy manifest k8s/mysql/cronjob-backup.yaml. CronJob działa codziennie o 03:00, robi dump przez mysqldump -h ${APP_NAME}-mysql, zapisuje go do hostPath /opt/${APP_NAME}-backups i trzyma lokalnie ostatnie 14 dni.

# Na serwerze utwórz katalog
mkdir -p /opt/app-backups

# Zastosuj CronJob
APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh k8s/mysql/cronjob-backup.yaml | kubectl apply -f -

Zalecenie: Lokalny hostPath nie jest strategią disaster recovery. Synchronizuj /opt/${APP_NAME}-backups poza VPS, np. do Hetzner Object Storage, Backblaze B2 albo S3 przez rclone, i okresowo testuj restore.

Restore checklist

  1. Pobierz wybrany plik .sql.gz z backupu lokalnego albo zewnętrznego storage.
  2. Zatrzymaj aplikację albo przełącz ją w maintenance mode, żeby nie pisała do bazy podczas odtwarzania.
  3. Odtwórz dump do MySQL:
gzip -dc mysql-YYYYMMDDTHHMMSSZ.sql.gz | kubectl -n app exec -i app-mysql-0 -- \
  mysql -u root -p<ROOT_PASSWORD>
  1. Uruchom php artisan migrate:status, sprawdź logi aplikacji i wykonaj smoke test checkoutu/panelu admina.

20. Najczęstsze problemy (troubleshooting)

Pod utknął w Pending

kubectl -n app describe pod <nazwa-poda>

Szukaj sekcji Events na dole. Typowe przyczyny:

  • Insufficient memory - za mało RAM na węźle
  • ImagePullBackOff - błędny pull secret lub zły adres obrazu
  • PVC not bound - problem ze storage class

ImagePullBackOff - nie może pobrać obrazu

kubectl -n app get secret ghcr-pull-secret -o yaml
# Sprawdź czy secret istnieje

# Sprawdź czy deploy token jest aktywny w GitLab
# Settings → Repository → Deploy tokens

Odtwórz sekret:

kubectl -n app delete secret ghcr-pull-secret
kubectl create secret docker-registry ghcr-pull-secret \
  --docker-server=registry.gitlab.com \
  --docker-username=<nowy-token-username> \
  --docker-password=<nowy-token-password> \
  --namespace=app

Certyfikat TLS nie jest wystawiony

kubectl -n app describe certificate app-tls
kubectl -n cert-manager logs deployment/cert-manager | grep ERROR

Najczęstsze przyczyny:

  • DNS jeszcze nie propagował (poczekaj 15 min)
  • Port 80 zablokowany przez firewall (Let's Encrypt używa HTTP challenge)
  • servicelb wyłączone bez chmurowego LB - porty 80/443 niedostępne na hoście; sprawdź sudo ss -tlnp | grep :80 na serwerze; jeśli nic nie nasłuchuje - patrz sekcja 4.1
  • Przekroczyłeś limit certyfikatów Let's Encrypt (5 na tydzień na domenę)

cert-manager: x509: certificate signed by unknown authority przy tworzeniu ClusterIssuer

Error from server (InternalError): error when creating "STDIN": Internal error
occurred: failed calling webhook "webhook.cert-manager.io": ... tls: failed to
verify certificate: x509: certificate signed by unknown authority

Race condition: pody cert-managera są Running, ale cainjector nie zdążył jeszcze wstrzyknąć certyfikatu serwującego do webhooka. kubectl rollout status tego nie wykrywa - pod jest "ready" zanim webhook faktycznie odpowiada.

bootstrap.sh ma na to probe (server-side dry-run w pętli, do 180 s). Jeśli mimo to trafisz na ten błąd przy ręcznej instalacji - po prostu poczekaj ~30 s i ponów kubectl apply ClusterIssuera:

# sprawdź że webhook faktycznie odpowiada
kubectl -n cert-manager get pods
until kubectl apply --dry-run=server -f k8s/cert-manager/cluster-issuer.yaml >/dev/null 2>&1; do
  echo "webhook not ready yet, retrying in 5s..."; sleep 5
done
kubectl apply -f k8s/cert-manager/cluster-issuer.yaml

Laravel zwraca błąd 500

kubectl -n app logs deployment/app-server --tail=50
kubectl -n app exec -it deployment/app-server -- cat storage/logs/laravel.log | tail -50

Migracja nie przeszła

# Sprawdź logi zakończonego joba
kubectl -n app get jobs
kubectl -n app logs job/app-migrate-<SHA>

Brak połączenia z MySQL

# Test z poda serwera
kubectl -n app exec -it deployment/app-server -- \
  php artisan tinker --execute="DB::connection()->getPdo(); echo 'OK';"

Sprawdź czy DB_HOST w sekrecie zgadza się z app-mysql.app.svc.cluster.local.

Typesense: kolekcja istnieje, ale num_documents=0

Po scout:import w logach kolekcja jest, ale dokumenty się nie indeksują. Sprawdź workera:

kubectl -n app logs deployment/app-queue --tail=50 | grep -A3 -i scout

Typowy błąd: Error importing document: Field 'is_featured' must be a bool - to znaczy że toSearchableArray() w modelu zwraca int zamiast bool (bo brakuje 'is_featured' => 'boolean' w $casts). Pomóc może rebuild obrazu z poprawnego commita lub dodanie jawnego rzutowania (bool) $this->is_featured w toSearchableArray().

Po naprawie:

# Wyczyść starą kolekcję i zaimportuj ponownie
kubectl -n app exec deployment/app-server -- php artisan scout:flush "App\Models\Product"
kubectl -n app exec deployment/app-server -- php artisan scout:import "App\Models\Product"

Excel export wywala fopen(.../laravel-excel/...): No such file or directory

Queued export Maatwebsite/Excel próbuje pisać do storage/framework/cache/laravel-excel/, a katalog nie istnieje na PVC. Utwórz go w każdym podzie korzystającym z tego volume (server + queue):

for pod in $(kubectl -n app get pods -l 'component in (server,queue)' -o name); do
  kubectl -n app exec $pod -- sh -c \
    'mkdir -p storage/framework/cache/laravel-excel && chown www-data:www-data storage/framework/cache/laravel-excel'
done

Trwałe rozwiązanie: dodaj mkdir -p do Dockerfile (warstwa przed USER www-data) - patrz sekcja 12.1.

Maile nie wychodzą - Name does not resolve

kubectl -n app exec deployment/app-server -- \
  php artisan tinker --execute='try{ Mail::raw("t",fn($m)=>$m->to("x@x")->subject("p")); echo "OK"; }catch(\Throwable $e){echo $e->getMessage();}'
# Connection could not be established with host "smtp.yourdomain.com:587": getaddrinfo failed

Najczęstsze przyczyny:

  1. MAIL_HOST w PROD_ENV to placeholder (smtp.yourdomain.com) - wpisz prawdziwy SMTP albo postaw mailpita (sekcja 12.5).
  2. MAIL_HOST=mailpit ale brak Service mailpit w klastrze - kubectl -n app get svc mailpit.
  3. Firewall serwera blokuje wyjściowy 587/465 - sprawdź nc -zv smtp.host 587 z poda.

Failed jobs rosną (queue:failed → setki rekordów)

Często widoczne po: zmianie schematu DB, refactorze nazw klas jobów, błędach Scout/Media. Workflow:

# 1. zobacz typy padających jobów
kubectl -n app exec deployment/app-server -- php artisan queue:failed | awk '{print $5}' | sort | uniq -c

# 2. zobacz jeden exception
kubectl -n app exec deployment/app-server -- php artisan tinker --execute='echo DB::table("failed_jobs")->latest("failed_at")->value("exception");' | head -c 500

# 3. po naprawie kodu - retry albo wyrzuć
kubectl -n app exec deployment/app-server -- php artisan queue:retry all
# lub: queue:flush  (kasuje wszystkie failed)

HPA nie skaluje

kubectl -n app get hpa
# Jeśli TARGETS = <unknown>/70% - metrics-server nie działa

# Sprawdź czy metrics-server jest zainstalowany
kubectl -n kube-system get deployment metrics-server

Jeśli metrics-server nie ma - zainstaluj:

kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

21. GlitchTip - śledzenie błędów

GlitchTip to self-hosted, open-source alternatywa dla Sentry. Używa tego samego SDK co Sentry - nie trzeba zmieniać kodu, wystarczy podmienić DSN na adres własnej instancji.

Wersja chartu: poniższe dotyczy oficjalnego chartu 8.2.0 (app v6.1.4). Chart 8.x nie ma już wbudowanego Postgresa - postgresql.enabled: true wymaga operatora CloudNativePG. Dlatego stawiamy własnego, standalone Postgresa (k8s/glitchtip/postgresql.yaml) i wskazujemy chartowi przez DATABASE_URL. Valkey (zamiennik Redisa) chart nadal dostarcza sam.

21.1 Przygotuj values.yaml i secret.yaml

W repo są dwa szablony. Nigdy nie commituj plików z sekretami - .gitignore ignoruje k8s/glitchtip/values.yaml i k8s/glitchtip/secret.yaml:

cp k8s/glitchtip/values.example.yaml k8s/glitchtip/values.yaml
cp k8s/glitchtip/secret.yaml.example k8s/glitchtip/secret.yaml

# wygeneruj sekrety:
openssl rand -hex 25                     # → SECRET_KEY
openssl rand -base64 24 | tr -d '/+='    # → POSTGRES_PASSWORD

k8s/glitchtip/secret.yaml - jeden Secret glitchtip-secrets, trzy klucze:

KluczCzym wypełnić
SECRET_KEYWygenerowany 50-znakowy hex. Po starcie nie zmieniaj - unieważnia sesje i tokeny.
POSTGRES_PASSWORDWygenerowane silne hasło do Postgresa.
DATABASE_URLpostgres://glitchtip:<POSTGRES_PASSWORD>@glitchtip-postgresql:5432/glitchtip - hasło musi być takie samo jak POSTGRES_PASSWORD.

k8s/glitchtip/values.yaml - konfiguracja Helm (bez sekretów):

KluczCzym wypełnić
glitchtip.domainPełny URL instancji, np. https://glitchtip.yourdomain.com (subdomena musi mieć rekord DNS A na IP serwera)
web.ingress.hosts[0].host + web.ingress.tls[0].hosts[0]Ta sama domena (bez https://)
web.extraEnvVarsEMAIL_URLSMTP do alertów: smtp://USER%40gmail.com:[email protected]:587 - znaki specjalne URL-encoded (@ w loginie → %40). Gmail wymaga App Password.
web.extraEnvVarsDEFAULT_FROM_EMAILAdres "Od" w mailach z alertami

Pozostałe pola (glitchtip.existingSecret, glitchtip.database.existingSecret, valkey.enabled, postgresql.enabled: false, web.ingress.className: traefik) są już ustawione poprawnie w szablonie - nie ruszaj.

21.2 Instalacja

Kolejność jest istotna: namespace → secret → Postgres → chart.

kubectl create namespace glitchtip --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f k8s/glitchtip/secret.yaml
kubectl apply -f k8s/glitchtip/postgresql.yaml
kubectl -n glitchtip rollout status statefulset/glitchtip-postgresql --timeout=3m

helm repo add glitchtip https://gitlab.com/api/v4/projects/16325141/packages/helm/stable --force-update
helm repo update glitchtip
helm upgrade --install glitchtip glitchtip/glitchtip \
  --namespace glitchtip \
  -f k8s/glitchtip/values.yaml

bootstrap.sh (Krok 13) robi dokładnie to samo automatycznie - pod warunkiem że values.yaml i secret.yaml istnieją. Jeśli ich nie ma, Krok 13 jest pomijany (bootstrap leci dalej - GlitchTip jest opcjonalny).

21.3 Konfiguracja po instalacji

  1. Otwórz https://glitchtip.yourdomain.com (poczekaj 1–2 min na certyfikat TLS od cert-manager)
  2. Utwórz organizację (np. app)
  3. Utwórz dwa projekty: app-server (platform: PHP/Laravel) i app-client (platform: Next.js)
  4. Skopiuj DSN-y z Settings → Client Keys (DSN) każdego projektu
  5. Wpisz do CI/CD:
  6. Triggernij deploy - od następnego rolloutu błędy lecą do Glitchtip.

Test: w Laravel pod admin shell:

kubectl -n app exec deployment/app-server -- \
  php artisan tinker --execute='throw new \Exception("glitchtip test event");'

Po ~30 sek event pojawi się w UI Glitchtip → Issues.


22. Rancher - zarządzanie klastrem przez UI

Jeśli w pracy korzystasz z Ranchera, możesz go postawić na tym samym VPS. Dostaniesz dokładnie to samo środowisko - podgląd podów, logi, shell do kontenera, zarządzanie secretami - wszystko przez przeglądarkę.

🚀 Najprościej: postaw Rancher i Uptime Kuma jednym poleceniem przez docker-compose.ops.yml.

W repo jest gotowy plik docker-compose.ops.yml (root projektu), który zawiera Rancher (porty 8080/8443) i Uptime Kuma (port 3001) z trwałymi wolumenami /opt/rancher i /opt/uptime-kuma.

⚠️ Wymagany Docker na serwerze. Te narzędzia działają jako kontenery Docker obok k3s (wzorzec monitor-the-monitor - patrz wcześniej). Czysty serwer k3s ma tylko containerd, nie ma Dockera. bootstrap.sh (Krok 14) wykrywa to i instaluje Docker automatycznie (curl -fsSL https://get.docker.com | sudo sh). Jeśli robisz to ręcznie - zainstaluj Docker najpierw. Docker i k3s/containerd współistnieją bez problemu (osobne sockety).

Użytkownik SSH: użyj konta, które ma passwordless sudo - ssh host "sudo …" nie ma TTY, więc sudo z hasłem zawiśnie. Domyślne konta chmurowe zwykle to mają (root na Hetzner, ubuntu na OVHcloud/AWS). Jeśli używasz własnego konta (np. deployer) - patrz ramka "Konto deployer do operacji ops" niżej.

# Załóżmy SSH_HOST=ubuntu@<IP_SERWERA>  (na Hetzner: root@<IP_SERWERA>)

# 0. Zainstaluj Docker jeśli go nie ma (k3s ma własny containerd - to osobna sprawa)
ssh $SSH_HOST "command -v docker || (curl -fsSL https://get.docker.com -o /tmp/d.sh && sudo sh /tmp/d.sh)"

# 1. Katalogi na hoście (scp nie umie sudo - /opt jest root-owned)
ssh $SSH_HOST "sudo mkdir -p /opt/rancher /opt/uptime-kuma && sudo chown -R 1000:1000 /opt/uptime-kuma"

# 2. Skopiuj plik do home użytkownika, potem przenieś z sudo do /opt
scp docker-compose.ops.yml $SSH_HOST:docker-compose.ops.yml
ssh $SSH_HOST "sudo mv ~/docker-compose.ops.yml /opt/docker-compose.ops.yml"

# 3. Uruchom (Docker, NIE k3s - to narzędzia operacyjne obok klastra)
ssh $SSH_HOST "cd /opt && sudo docker compose -f docker-compose.ops.yml up -d"

# Status:
ssh $SSH_HOST "cd /opt && sudo docker compose -f docker-compose.ops.yml ps"

bootstrap.sh (Krok 14) robi dokładnie to samo automatycznie - pyta o user@ip, instaluje Docker jeśli trzeba.

Reszta tej sekcji opisuje co dalej: pierwsze logowanie do Ranchera (22.2), import klastra k3s (22.3), zabezpieczenie portów (22.5). Konfiguracja Uptime Kuma jest w sekcji "💡 Bonus: Uptime Kuma" niżej.

Konto deployer do operacji ops

Jeśli - zgodnie z sekcją 3.6 Opcja B - masz konto deployer i chcesz go używać do Kroku 13 / docker-compose.ops.yml, musi mieć passwordless sudo (bo ssh host "sudo …" nie ma TTY na hasło). Jednorazowo na serwerze:

echo 'deployer ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/deployer
sudo chmod 440 /etc/sudoers.d/deployer

To jedyne miejsce w całym wdrożeniu, gdzie SSH na serwer w ogóle jest potrzebne - operacje kubectl (Kroki 1–12) idą przez KUBECONFIG, nie przez SSH. Konto deployer nie musi być w grupie docker - używamy sudo docker compose.

22.1 Instalacja Ranchera (ręcznie, bez compose)

Jeśli wolisz nie używać compose:

docker run -d \
  --name rancher \
  --restart=unless-stopped \
  --privileged \
  -p 8080:80 -p 8443:443 \
  -v /opt/rancher:/var/lib/rancher \
  rancher/rancher:latest

Poczekaj ~2 minuty, a następnie wejdź na:

https://<IP_SERWERA>:8443

Uwaga: Rancher używa self-signed certyfikatu przy pierwszym uruchomieniu - przeglądarka pokaże ostrzeżenie, kliknij "Proceed anyway".

22.2 Pierwsze logowanie

Pobierz hasło bootstrapowe:

docker logs rancher 2>&1 | grep "Bootstrap Password"

Zaloguj się i ustaw nowe hasło.

22.3 Importuj klaster k3s

  1. W Rancherze kliknij Import ExistingGeneric
  2. Nadaj nazwę klastrowi, np. app
  3. Rancher wygeneruje komendę kubectl apply - uruchom ją na serwerze:
kubectl apply -f https://<IP_RANCHERA>:8443/v3/import/xxxxx.yaml

Po ~1 minucie klaster pojawi się w Rancherze ze statusem Active.

22.4 Co możesz robić w UI

FunkcjaGdzie w Rancherze
Podgląd wszystkich podówWorkloads → Pods
Logi poda (live)Pod → ⋮ → View Logs
Shell do konteneraPod → ⋮ → Execute Shell
Restart deploymentuWorkloads → Deployments → ⋮ → Redeploy
Podgląd secretówStorage → Secrets
Edycja zmiennych envDeployment → Edit Config
Zużycie CPU / RAMCluster → Metrics
CronJoby i JobyWorkloads → CronJobs / Jobs

22.5 Zabezpieczenie panelu Ranchera

Domyślnie Rancher jest dostępny publicznie na porcie 8443. Ogranicz dostęp przez firewall Hetzner (panel → Firewall) lub bezpośrednio na serwerze:

ufw allow ssh
ufw allow 80
ufw allow 443
ufw allow 6443         # kubectl API - tylko Twój IP
ufw deny 8443          # zablokuj publicznie
ufw allow from <TWÓJ_IP> to any port 8443
ufw enable

23. Czyszczenie dysku

k3s akumuluje stare obrazy kontenerów przy każdym deploymencie. Po kilku miesiącach możesz stracić kilkanaście GB - warto to zautomatyzować.

23.1 Sprawdź zajętość dysku

df -h /

# Ile zajmują obrazy k3s (containerd)
du -sh /var/lib/rancher/k3s/agent/containerd/

23.2 Ręczne czyszczenie

k3s używa containerd (nie Dockera). Do zarządzania obrazami służy crictl:

# Usuń wszystkie nieużywane obrazy
k3s crictl rmi --prune

# Sprawdź co zostało
k3s crictl images

Jeśli masz też Dockera na serwerze (Rancher, Uptime Kuma):

docker system prune -af

23.3 Automatyczne czyszczenie - CronJob

kubectl apply -f k8s/maintenance/cronjob-image-cleanup.yaml

CronJob uruchamia się co niedzielę o 2:00 i usuwa nieużywane obrazy z containerd.


24. k9s - terminalowy panel zarządzania

k9s to terminalowy UI dla Kubernetes - jak Rancher, ale w konsoli. Przydatny gdy jesteś już połączony SSH i nie chcesz otwierać przeglądarki.

24.1 Instalacja

macOS:

brew install k9s

Linux (serwer lub lokalnie):

VERSION=$(curl -s https://api.github.com/repos/derailed/k9s/releases/latest | grep tag_name | cut -d '"' -f 4)
curl -L "https://github.com/derailed/k9s/releases/download/${VERSION}/k9s_Linux_amd64.tar.gz" \
  | tar xz -C /usr/local/bin k9s

Windows:

winget install k9s

24.2 Uruchomienie

k9s
# lub od razu w konkretnym namespace
k9s -n app

24.3 Najważniejsze skróty

KlawiszAkcja
:podlista podów
:deploylista deploymentów
:secretlista secretów
:joblista jobów
llogi poda (live)
sshell do kontenera
ddescribe (szczegóły)
ctrl+rrestart deploymentu
/filtrowanie po nazwie
?pełna lista skrótów
qwyjście / poprzedni widok

25. Rotacja secretów - aktualizacja .env i haseł bez downtime'u

Najprościej (zalecane): zaktualizuj wartości w PROD_ENV (GitHub Variables / GitLab CI Variables) i triggernij deploy - pipeline sam zsynchronizuje sekret app-server-env i zrobi rolling restart. Sekcje poniżej są dla operacji wykonywanych ręcznie z kubectl (bez CI/CD).

25.1 Aktualizacja Laravel .env

Single source of truth dla Laravel .env w klastrze to sekret app-server-env. Bez CI/CD wygeneruj go z lokalnego server/.env.production:

# 1) zaktualizuj wartości w server/.env.production (gitignored)
# 2) re-create secret (idempotentne - nadpisuje istniejący):
kubectl create secret generic app-server-env \
  --from-file=.env=server/.env.production \
  --namespace=app \
  --dry-run=client -o yaml | kubectl apply -f -

# 3) rolling restart - nowe pody startują z nowym sekretem zanim stare padną
kubectl -n app rollout restart deployment/app-server
kubectl -n app rollout restart deployment/app-queue

25.2 Zmiana hasła MySQL

Krok 1 - zmień hasło w bazie:

kubectl -n app exec -it app-mysql-0 -- mysql -u root -p<STARE_HASŁO>
ALTER USER 'app'@'%' IDENTIFIED BY 'NoweHaslo123!';
FLUSH PRIVILEGES;
EXIT;

Krok 2 - zaktualizuj oba sekrety i zrestartuj:

# Sekret MySQL - odśwież wartością z --from-literal (bootstrap nigdy nie tworzy
# pliku k8s/mysql/secret.yaml - używa kubectl CLI bezpośrednio).
kubectl create secret generic app-mysql \
  --from-literal=root-password='<NOWE_ROOT>' \
  --from-literal=username='app' \
  --from-literal=password='NoweHaslo123!' \
  --namespace=app \
  --dry-run=client -o yaml | kubectl apply -f -

# Zaktualizuj DB_PASSWORD w server/.env.production, potem:
kubectl create secret generic app-server-env \
  --from-file=.env=server/.env.production \
  --namespace=app \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl -n app rollout restart deployment/app-server
kubectl -n app rollout restart deployment/app-queue

25.3 Zmiana hasła Redis

# Sekret Redis
kubectl create secret generic app-redis \
  --from-literal=password='<NOWE_HASLO_REDIS>' \
  --namespace=app \
  --dry-run=client -o yaml | kubectl apply -f -

# Zaktualizuj REDIS_PASSWORD w server/.env.production, potem:
kubectl create secret generic app-server-env \
  --from-file=.env=server/.env.production \
  --namespace=app \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl -n app rollout restart deployment/app-redis
kubectl -n app rollout restart deployment/app-server
kubectl -n app rollout restart deployment/app-queue

Uwaga: Restart Redis czyści cache i sesje - użytkownicy zostaną wylogowani. Planuj poza godzinami szczytu.


26. Reset serwera pod inną aplikację

To nie jest codzienny workflow, ale przydaje się, gdy chcesz użyć tego samego VPS-a pod zupełnie inną aplikację bez reinstalacji systemu, ponownego ustawiania SSH, firewalla i podstawowych pakietów.

Masz dwa poziomy czyszczenia:

CelCo zrobićCo zostaje
Usunąć tylko tę aplikacjękubectl delete namespace appk3s, Traefik, cert-manager, konfiguracja klastra
Wyczyścić cały Kubernetesk3s-uninstall.shsystem Linux, SSH, firewall, DNS, pakiety systemowe

26.1 Zanim usuniesz

Pełny uninstall k3s usuwa zasoby klastra, sekrety, PVC i lokalne wolumeny local-path. Dla tej aplikacji oznacza to m.in. MySQL, Redis, Typesense, uploady trzymane w PVC i wszystkie sekrety Kubernetes.

Przed resetem zrób minimum:

# Backup bazy
kubectl -n app exec app-mysql-0 -- \
  mysqldump -u root -p<ROOT_PASSWORD> app \
  > backup_$(date +%Y%m%d_%H%M%S).sql

# Eksport najważniejszych sekretów do audytu/odtworzenia
kubectl -n app get secret app-server-env -o yaml > app-server-env.backup.yaml
kubectl -n app get secret app-mysql -o yaml > app-mysql.backup.yaml

# Lista zasobów przed skasowaniem
kubectl -n app get all,ingress,certificate,pvc,secrets

Jeśli uploady są na S3/R2, zwykle wystarczy backup bazy i konfiguracji. Jeśli pliki są w PVC albo lokalnym storage, skopiuj je osobno z poda lub wolumenu przed uninstallacją.

26.2 Opcja lżejsza - usuń tylko aplikację

Jeśli chcesz postawić nową aplikację na tym samym działającym k3s, najczęściej wystarczy skasować namespace:

kubectl delete namespace app
kubectl get namespace app

Po usunięciu namespace możesz uruchomić bootstrap/deploy nowej aplikacji z innym APP_NAME i KUBE_NAMESPACE. Ten wariant zostawia cert-manager, Traefika, Ranchera/k9s i całą konfigurację klastra.

26.3 Opcja pełna - usuń cały k3s

Na serwerze control-plane uruchom:

sudo /usr/local/bin/k3s-uninstall.sh

Jeśli czyścisz osobny node-agent, użyj:

sudo /usr/local/bin/k3s-agent-uninstall.sh

Skrypt zatrzymuje usługi k3s, usuwa binarki, konfigurację systemd, dane klastra i katalogi zarządzane przez k3s. Po nim kubectl nie będzie już działał z tym klastrem, a lokalny kubeconfig wskazujący na ten serwer stanie się nieaktualny.

Sprawdzenie po uninstallu:

systemctl status k3s
command -v k3s
ls /etc/rancher/k3s
ls /var/lib/rancher/k3s

Brak usługi/binarki/katalogów oznacza, że serwer jest gotowy na świeżą instalację k3s. Zacznij ponownie od sekcji 4. Instalacja k3s, a potem wykonaj bootstrap dla nowej aplikacji.

26.4 Narzędzia uruchomione obok k3s

Sekcje o Rancherze i Uptime Kuma pokazują wariant Dockerowy, czyli kontenery uruchomione obok k3s. k3s-uninstall.sh ich nie usuwa.

Jeśli chcesz wyczyścić również te narzędzia:

docker ps -a
docker stop <container>
docker rm <container>
docker volume ls
docker volume rm <volume>

Nie usuwaj wolumenów Dockera, jeśli trzymasz tam dane, które mają przetrwać reset.


💡 Bonus: Staging namespace

Możesz postawić środowisko stagingowe w osobnym namespace app-staging na tym samym klastrze - bez dodatkowych kosztów.

# Utwórz namespace staging
APP_NAME=app KUBE_NAMESPACE=app-staging ./k8s/render.sh k8s/namespace.yaml | kubectl apply -f -

# Stwórz sekret z osobnego pliku .env dla stagingu
# (np. server/.env.staging - gitignored razem z .env.production)
kubectl create secret generic app-staging-server-env \
  --from-file=.env=server/.env.staging \
  --namespace=app-staging \
  --dry-run=client -o yaml | kubectl apply -f -

# MySQL / Redis sekrety analogicznie:
kubectl create secret generic app-staging-mysql \
  --from-literal=root-password='<STAGING_ROOT>' \
  --from-literal=username='app' \
  --from-literal=password='<STAGING_APP>' \
  --namespace=app-staging \
  --dry-run=client -o yaml | kubectl apply -f -

W CI/CD dodaj job uruchamiany na branchu develop:

# GitHub Actions - dodaj do .github/workflows/deploy.yml
deploy-staging:
  name: Deploy to Staging
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/develop'
  steps:
    - name: Deploy server to staging
      run: |
        APP_NAME=app KUBE_NAMESPACE=app-staging IMAGE_SERVER="ghcr.io/${{ github.repository_owner }}/app-server:${{ github.sha }}" \
          ./k8s/render.sh k8s/server/deployment.yaml | kubectl apply -f -
        kubectl -n app-staging rollout status deployment/app-server --timeout=5m
# GitLab CI - dodaj do .gitlab-ci.yml
deploy-staging:
  stage: deploy
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"
  script:
    - APP_NAME=app KUBE_NAMESPACE=app-staging IMAGE_SERVER="${SERVER_IMAGE}:${CI_COMMIT_SHORT_SHA}" ./k8s/render.sh k8s/server/deployment.yaml | kubectl apply -f -
    - kubectl -n app-staging rollout status deployment/app-server --timeout=5m

Dla stagingowego ingressu użyj subdomeny staging.yourdomain.com.


💡 Bonus: Uptime Kuma - monitoring dostępności

Uptime Kuma to self-hosted alternatywa dla UptimeRobot.

Zalecane: uruchom przez docker-compose.ops.yml razem z Rancherem (patrz sekcja 22 wyżej):

# Na serwerze
cd /opt && sudo docker compose -f docker-compose.ops.yml up -d uptime-kuma

Alternatywnie samodzielnie:

docker run -d \
  --name uptime-kuma \
  --restart=unless-stopped \
  -p 3001:3001 \
  -v /opt/uptime-kuma:/app/data \
  louislam/uptime-kuma:latest

Wejdź na http://<IP_SERWERA>:3001 i dodaj monitory dla:

  • https://yourdomain.com - frontend
  • https://admin.yourdomain.com/health - Laravel API
  • https://admin.yourdomain.com/admin - panel admina

Wysyła powiadomienia przez Slack, email, Telegram i wiele innych kanałów.

Zabezpiecz port 3001 tak samo jak 8443 - ogranicz do swojego IP przez UFW.


💡 Bonus: MinIO - self-hosted S3 dla 2+ podów

Gdy chcesz skalować do replicas: 2+, PVC z ReadWriteOnce nie wystarczy - dwa pody nie mogą jednocześnie zapisywać do tego samego wolumenu. Rozwiązaniem jest MinIO - self-hosted storage kompatybilny z API Amazon S3.

replicas: 2

Pod A ──► MinIO API (port 9000) ──► /data (PVC)
Pod B ──►

Oba pody piszą do MinIO przez HTTP - MinIO sam zarządza dyskiem.

Wdrożenie MinIO

Najprościej: wybierz Install MinIO? [y/N] podczas ./k8s/bootstrap.sh. Domyślnie odpowiedź to N, więc standardowe wdrożenie nadal używa app-server-storage PVC. Jeśli wybierzesz Y, bootstrap utworzy sekret, PVC, Deployment, Service oraz bucket przez Job k8s/minio/job-create-bucket.yaml.

Ręcznie:

# Sekret (wyrenderuj szablon, uzupełnij wartości CHANGE_ME i zaaplikuj)
APP_NAME=app KUBE_NAMESPACE=app ./k8s/render.sh k8s/minio/secret.yaml.example > /tmp/minio-secret.yaml
$EDITOR /tmp/minio-secret.yaml
kubectl apply -f /tmp/minio-secret.yaml

# PVC + Deployment + Service + bucket
APP_NAME=app KUBE_NAMESPACE=app MINIO_BUCKET=app ./k8s/render.sh \
  k8s/minio/pvc.yaml \
  k8s/minio/deployment.yaml \
  k8s/minio/service.yaml \
  k8s/minio/job-create-bucket.yaml | kubectl apply -f -

# Sprawdź
kubectl -n app get pods | grep minio
# app-minio-xxxxxxxxx-xxxxx   1/1   Running   0   1m

Utwórz bucket

Jeśli użyłeś bootstrap.sh albo k8s/minio/job-create-bucket.yaml, bucket został utworzony automatycznie. Panel webowy MinIO jest dostępny na porcie 9001. Utwórz tymczasowe przekierowanie:

kubectl -n app port-forward svc/app-minio 9001:9001

Wejdź na http://localhost:9001, zaloguj się danymi z sekretu i sprawdź bucket app.

Lub przez CLI bez UI:

kubectl -n app exec deployment/app-minio -- \
  mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD

kubectl -n app exec deployment/app-minio -- \
  mc mb local/app

Konfiguracja Laravel (.env)

FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=<root-user z sekretu>
AWS_SECRET_ACCESS_KEY=<root-password z sekretu>
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=app
AWS_ENDPOINT=http://app-minio.app.svc.cluster.local:9000
AWS_USE_PATH_STYLE_ENDPOINT=true   # wymagane dla MinIO

Publiczny dostęp do plików: MinIO działa wewnątrz klastra. Żeby pliki były dostępne publicznie, dodaj regułę w Ingress lub skonfiguruj MinIO bucket jako publiczny i wystaw port 9000 przez Ingress na osobnej subdomenie (np. storage.yourdomain.com).

Migracja z PVC na MinIO

Jeśli masz już pliki na PVC i chcesz przenieść na MinIO:

# Skopiuj pliki z poda serwera do MinIO
kubectl -n app exec deployment/app-server -- \
  aws s3 sync storage/app/public s3://app/public \
  --endpoint-url http://app-minio.app.svc.cluster.local:9000

# Następnie zmień FILESYSTEM_DISK=s3 w PROD_ENV i zrestartuj
kubectl -n app rollout restart deployment/app-server
kubectl -n app rollout restart deployment/app-queue

Kod źródłowy

Pełny kod z tego artykułu znajdziesz na GitHub.

Podsumowanie

Masz teraz pełny klaster k3s z:

  • ✅ Automatycznym TLS (Let's Encrypt przez cert-manager)
  • ✅ HTTP → HTTPS redirect (Traefik)
  • ✅ Zero-downtime deployów (rolling update)
  • ✅ Migracjami przed deployem (Job)
  • ✅ Automatycznym restartem po awarii (Kubernetes)
  • ✅ HPA - autoskalowaniem przy obciążeniu
  • ✅ CI/CD (GitHub Actions lub GitLab) - lintowanie, testy, build, deploy
  • ✅ MySQL z persystentnym wolumenem
  • ✅ Redis z persystentnym wolumenem
  • ✅ Codziennymi backupami MySQL

Całość za ~$10-12/miesiąc na Hetzner CX33.


Obserwuj mnie na LinkedIn po więcej porad Laravel i DevOps!

Komentarze (0)
Zostaw komentarz

© 2026 Wszelkie prawa zastrzeżone.