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
- Czym jest k3s i dlaczego nie zwykły k8s?
- Architektura tego wdrożenia
- Przygotowanie serwera (Hetzner)
- Instalacja k3s
- 4.4 Automatyczna konfiguracja klastra - bootstrap.sh ← zalecane po instalacji k3s
- Instalacja cert-manager (SSL Let's Encrypt)
- Konfiguracja Traefik (HTTPS + przekierowanie)
- Przygotowanie klastra - namespace i sekrety
- Wdrożenie bazy danych i cache (MySQL + Redis)
- Wdrożenie Gotenberg (PDF)
- Wdrożenie Typesense (full-text search)
- Pull Secret dla Container Registry (GHCR / GitLab)
- Wdrożenie aplikacji (serwer + klient)
- Jak CI/CD łączy się z k3s?
- Konfiguracja GitHub Actions
- Konfiguracja GitLab CI/CD
- Pierwsze wdrożenie przez CI
- Weryfikacja - czy wszystko działa?
- Codzienna obsługa - logi, restarty, aktualizacje
- Backup MySQL
- Najczęstsze problemy (troubleshooting)
- GlitchTip - śledzenie błędów
- Rancher - zarządzanie klastrem przez UI
- Czyszczenie dysku
- k9s - terminalowy panel zarządzania
- Rotacja secretów - aktualizacja .env i haseł bez downtime'u
- Reset serwera pod inną aplikację
- Kod źródłowy
- 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ów | 1 komenda |
| Wymagany serwer | min. 4 węzły | 1 węzeł |
| Kompatybilność z k8s | 100% | 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 upzatrzymuje 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):
- Location: Falkenstein (Europa - niskie ping z Polski)
- Image: Ubuntu 24.04 LTS
- Type: CX33 (4 vCPU, 8 GB RAM, 80 GB NVMe) - ~$10/mc
- Networking: Włącz publiczne IPv4 + IPv6
- SSH key: Wklej swój klucz publiczny (
cat ~/.ssh/id_ed25519.pub) - Firewall: Utwórz nowy z regułami:
| Typ | Protokół | Port | Źródło |
|---|---|---|---|
| Inbound | TCP | 22 | Twój IP (lub 0.0.0.0/0 jeśli dynamiczny) |
| Inbound | TCP | 80 | 0.0.0.0/0, ::/0 |
| Inbound | TCP | 443 | 0.0.0.0/0, ::/0 |
| Inbound | TCP | 6443 | Twó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 rootnaUser 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
HelmChartConfigz 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
kubectlwykonujesz u siebie.
kubectlto klient HTTP - wysyła polecenia do serwera przez port 6443. Nie musisz być zalogowany przez SSH. Upewnij się, że masz ustawioneexport 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:
| Opcja | Kiedy 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:
KUBECONFIGwskazuje 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:
| Pytanie | Co wpisać |
|---|---|
| Application name / prefix | Domyślnie app; zasoby będą miały prefiks <APP_NAME>- |
| Kubernetes namespace | Domyślnie taki sam jak APP_NAME; można podać inny namespace |
| Hasło MySQL root | Silne hasło, min. 16 znaków |
| Nazwa użytkownika MySQL | Domyślnie app |
| Hasło MySQL app | Silne hasło, min. 16 znaków |
| Hasło Redis | Silne hasło, min. 16 znaków |
| Typesense API key | Losowy klucz, min. 16 znaków |
| Typ CI/CD | 1 = GitHub Actions, 2 = GitLab CI, Enter = pomiń |
| GitHub/GitLab token | Do pull secret dla prywatnego rejestru obrazów |
| E-mail Let's Encrypt | Powiadomienia 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 |
| GlitchTip | y 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:
- HelmChartConfig - konfiguruje Traefik tak, żeby ruch HTTP (port 80) był automatycznie przekierowywany na HTTPS (port 443)
- 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 przezk8s/render.sh. Domyślne wartości toAPP_NAME=appiKUBE_NAMESPACE=app, więc przykłady zapp-*pokazują zachowanie domyślne. Jeśli używasz innego namespace, zastąp w przykładachappwartościąKUBE_NAMESPACE; jeśli używasz innego prefiksu zasobów, zastąpapp-*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 zmiennejPROD_ENVprzy każdym deploymencie (krok 0 w pipeline). GitLab jako alternatywa używaSERVER_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-argzENV_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 DNSapp-mysql-0.app-mysql.app.svc.cluster.localzawsze 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
10. Wdrożenie Typesense (full-text search)
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-secretjest 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
usernameitoken- 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-yDane 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:cachewywala się przy starcie- queued Excel exports padają z
fopen(... laravel-excel/...): No such file or directory- sesje filesowe fail-ują
Jeśli Twój
Dockerfilenie tworzy tych katalogów w obrazie, dodaj je docommand/entrypointpoda 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/cacheNajlepsza praktyka: dodaj tę linię do
Dockerfile(warstwa zaraz przedUSER 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.comjako placeholder - wszystkie maile fail-ują zgetaddrinfo 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ą /app → app-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:
- Wymagany queue worker - bez
app-queueRunning eksporty nigdy nie powstaną. - Wymagany katalog cache - worker pisze pliki tymczasowe do
storage/framework/cache/laravel-excel/. Jeśli katalog nie istnieje, queue job fail-uje z:
Patrz "Co przechowuje PVC?" wyżej -ErrorException: fopen(.../storage/framework/cache/laravel-excel/laravel-excel-XXX.xlsx): No such file or directorymkdir -p storage/framework/cache/laravel-excel. - Disk =
localwconfig/excel.phpceluje wstorage/app(lubstorage/app/privateod Laravel 11). Jeśli chcesz że pliki przeżyją restart poda - to PVC obsługuje, jeślireplicas: 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 SSH | Kubernetes |
|---|---|
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 (zsed), 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)
| Secret | Opis |
|---|---|
KUBECONFIG_PROD | Surowa treść kubeconfig - patrz sekcja 13 |
# Jak uzyskać wartość (bez base64):
cat ~/.kube/config-hetzner
Variables (widoczne i edytowalne w UI)
| Variable | Przykład | Opis |
|---|---|---|
APP_NAME | app | Prefiks zasobów i nazw obrazów; domyślnie app |
KUBE_NAMESPACE | app | Namespace 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_ENVjako 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:
- Najpierw aktualizuje k8s Secret z
PROD_ENV- migracje dostają świeży.env, bo startują jako nowy Job - Uruchamia
php artisan migrate --forcejako jednorazowy Job z nowym obrazem - Czeka na jego zakończenie (max 2 minuty)
- Dopiero potem robi rolling update deploymentów
- Jawnie restartuje
app-serveriapp-queue, bo.envjest montowany przezsubPathi 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
| Zmienna | Typ | Masked | Protected | Opis |
|---|---|---|---|---|
APP_NAME | Variable | ❌ | ✅ | Prefiks zasobów i obrazów; domyślnie app |
KUBE_NAMESPACE | Variable | ❌ | ✅ | Namespace; domyślnie APP_NAME |
KUBECONFIG | Variable | ✅ | ✅ | Surowa treść kubeconfig - patrz sekcja 13 |
SERVER_ENV | Variable | ✅ | ✅ | Pełna treść server/.env.production |
ENV_CLIENT_PROD | Variable | ❌ | ❌ | Build-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 rejestruCI_REGISTRY_USER- użytkownikCI_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.jshttps://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}-backupspoza VPS, np. do Hetzner Object Storage, Backblaze B2 albo S3 przezrclone, i okresowo testuj restore.
Restore checklist
- Pobierz wybrany plik
.sql.gzz backupu lokalnego albo zewnętrznego storage. - Zatrzymaj aplikację albo przełącz ją w maintenance mode, żeby nie pisała do bazy podczas odtwarzania.
- 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>
- 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ęźleImagePullBackOff- błędny pull secret lub zły adres obrazuPVC 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 :80na 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:
MAIL_HOSTwPROD_ENVto placeholder (smtp.yourdomain.com) - wpisz prawdziwy SMTP albo postaw mailpita (sekcja 12.5).MAIL_HOST=mailpitale brak Servicemailpitw klastrze -kubectl -n app get svc mailpit.- Firewall serwera blokuje wyjściowy 587/465 - sprawdź
nc -zv smtp.host 587z 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: truewymaga operatora CloudNativePG. Dlatego stawiamy własnego, standalone Postgresa (k8s/glitchtip/postgresql.yaml) i wskazujemy chartowi przezDATABASE_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:
| Klucz | Czym wypełnić |
|---|---|
SECRET_KEY | Wygenerowany 50-znakowy hex. Po starcie nie zmieniaj - unieważnia sesje i tokeny. |
POSTGRES_PASSWORD | Wygenerowane silne hasło do Postgresa. |
DATABASE_URL | postgres://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):
| Klucz | Czym wypełnić |
|---|---|
glitchtip.domain | Peł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.extraEnvVars → EMAIL_URL | SMTP do alertów: smtp://USER%40gmail.com:[email protected]:587 - znaki specjalne URL-encoded (@ w loginie → %40). Gmail wymaga App Password. |
web.extraEnvVars → DEFAULT_FROM_EMAIL | Adres "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
- Otwórz
https://glitchtip.yourdomain.com(poczekaj 1–2 min na certyfikat TLS od cert-manager) - Utwórz organizację (np.
app) - Utwórz dwa projekty:
app-server(platform: PHP/Laravel) iapp-client(platform: Next.js) - Skopiuj DSN-y z Settings → Client Keys (DSN) każdego projektu
- Wpisz do CI/CD:
- Laravel:
GLITCHTIP_DSN=https://[email protected]/1(wPROD_ENV) - Next.js (build arg):
NEXT_PUBLIC_GLITCHTIP_DSN=https://[email protected]/2(wENV_CLIENT_PROD)
- Laravel:
- 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/rancheri/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ą (rootna Hetzner,ubuntuna OVHcloud/AWS). Jeśli używasz własnego konta (np.deployer) - patrz ramka "Kontodeployerdo 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 ouser@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
deployerdo operacji opsJeśli - zgodnie z sekcją 3.6 Opcja B - masz konto
deployeri chcesz go używać do Kroku 13 /docker-compose.ops.yml, musi mieć passwordless sudo (bossh 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/deployerTo jedyne miejsce w całym wdrożeniu, gdzie SSH na serwer w ogóle jest potrzebne - operacje
kubectl(Kroki 1–12) idą przezKUBECONFIG, nie przez SSH. Kontodeployernie musi być w grupiedocker- używamysudo 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
- W Rancherze kliknij Import Existing → Generic
- Nadaj nazwę klastrowi, np.
app - 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
| Funkcja | Gdzie w Rancherze |
|---|---|
| Podgląd wszystkich podów | Workloads → Pods |
| Logi poda (live) | Pod → ⋮ → View Logs |
| Shell do kontenera | Pod → ⋮ → Execute Shell |
| Restart deploymentu | Workloads → Deployments → ⋮ → Redeploy |
| Podgląd secretów | Storage → Secrets |
| Edycja zmiennych env | Deployment → Edit Config |
| Zużycie CPU / RAM | Cluster → Metrics |
| CronJoby i Joby | Workloads → 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
| Klawisz | Akcja |
|---|---|
:pod | lista podów |
:deploy | lista deploymentów |
:secret | lista secretów |
:job | lista jobów |
l | logi poda (live) |
s | shell do kontenera |
d | describe (szczegóły) |
ctrl+r | restart deploymentu |
/ | filtrowanie po nazwie |
? | pełna lista skrótów |
q | wyjś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 sekretapp-server-envi zrobi rolling restart. Sekcje poniżej są dla operacji wykonywanych ręcznie zkubectl(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:
| Cel | Co zrobić | Co zostaje |
|---|---|---|
| Usunąć tylko tę aplikację | kubectl delete namespace app | k3s, Traefik, cert-manager, konfiguracja klastra |
| Wyczyścić cały Kubernetes | k3s-uninstall.sh | system 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- frontendhttps://admin.yourdomain.com/health- Laravel APIhttps://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!