← Volver al blog

Kubernetes Production-Grade en un Solo Servidor: La Guía Completa

Por qué hacer esto

Un solo servidor. Todo el stack de producción. Sin excusas.

La idea es simple: montar un cluster Kubernetes que lleve el mismo stack que usarías en AKS o EKS — service mesh, TLS automático, observabilidad, seguridad en capas — pero en tu propio hierro. No es un minikube. No es un laboratorio de pruebas. Es un entorno que sirve workloads reales a usuarios reales.

¿Por qué un solo nodo? Porque para proyectos personales, side projects, y experimentación seria, un servidor de 15-30€/mes te da más que suficiente. Y la experiencia de operar este stack es transferible directamente a clusters multi-nodo en cloud.

El resultado final: un cluster donde desplegar una app nueva es crear un namespace, labelearlo para el service mesh, crear un HTTPRoute, y hacer kubectl apply. TLS automático, DNS automático, métricas, logs, y seguridad — todo ya está ahí.

El servidor y requisitos previos

Lo que necesitas:

  • Servidor con Ubuntu 22.04+ — mínimo 4 vCPU y 8GB de RAM. Con todo el stack corriendo, el consumo base ronda los 4-5GB de RAM. Si vas a correr workloads encima, 16GB es más cómodo.
  • cgroup v2 — Ubuntu 22.04+ lo trae habilitado por defecto. Verifica con stat -fc %T /sys/fs/cgroup (debe devolver cgroup2fs).
  • Dominios en Cloudflare — necesarios para DNS-01 challenge (wildcard certs) y External DNS (creación automática de registros A).
  • API Token de Cloudflare con permisos Zone:DNS:Edit + Zone:Zone:Read para todas las zonas que vayas a usar.
  • Tailscale instalado — para acceso seguro al API Server.
API Server y seguridad

El API Server de Kubernetes (puerto 6443) nunca debe estar expuesto a internet. Usamos Tailscale como VPN para acceder desde fuera, y nftables para filtrar por interfaz de red. Si alguien llega a tu 6443, tiene las llaves del reino.

Base: kubeadm + containerd

Preparar el sistema

Primero, los módulos del kernel y parámetros de red que Kubernetes necesita:

# Módulos del kernel
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

sudo modprobe overlay
sudo modprobe br_netfilter

# Parámetros de red
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF

sudo sysctl --system

Instalar containerd

sudo apt-get update
sudo apt-get install -y containerd

sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml

# Habilitar SystemdCgroup — crítico para cgroup v2
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml

sudo systemctl restart containerd
sudo systemctl enable containerd
SystemdCgroup

Si no habilitas SystemdCgroup = true, kubelet y containerd usarán drivers de cgroup diferentes. El resultado: pods que mueren aleatoriamente, kubelet que se reinicia, y horas de debugging. Es el error más común en instalaciones con kubeadm.

Instalar kubeadm, kubelet, kubectl

sudo apt-get install -y apt-transport-https ca-certificates curl gpg

curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.32/deb/Release.key | \
  sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.32/deb/ /' | \
  sudo tee /etc/apt/sources.list.d/kubernetes.list

sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl

Inicializar el cluster

sudo kubeadm init \
  --pod-network-cidr=10.244.0.0/16 \
  --service-cidr=10.96.0.0/16 \
  --skip-phases=addon/kube-proxy
Sin kube-proxy

Saltamos kube-proxy con --skip-phases=addon/kube-proxy porque Calico en modo eBPF lo reemplaza completamente. eBPF maneja el balanceo de carga de servicios directamente en el kernel, sin las cadenas de iptables que kube-proxy genera. Mejor rendimiento, menos complejidad.

Después del init, configura el acceso y elimina el taint del nodo master:

mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

# Permitir que el nodo master ejecute workloads
kubectl taint nodes --all node-role.kubernetes.io/control-plane-

En un cluster de un solo nodo, este último paso es obligatorio. Sin él, nada se programa en tu único nodo.

CNI: Calico con eBPF

Por qué Calico eBPF

Calico con eBPF te da tres cosas que el modo iptables no:

  1. Reemplaza kube-proxy — balanceo de carga de servicios en eBPF, sin iptables
  2. Mejor rendimiento — especialmente con muchos servicios, donde las cadenas de iptables se vuelven lineales
  3. Visibilidad de red nativa — Calico puede usar los datos de eBPF para políticas y métricas

Instalar el Tigera Operator

helm repo add projectcalico https://docs.tigera.io/calico/charts
helm repo update

kubectl create namespace tigera-operator

helm install calico projectcalico/tigera-operator \
  --version v3.29.2 \
  --namespace tigera-operator

Ahora viene un gotcha real: necesitas esperar a que el operator registre los CRDs antes de aplicar el recurso de Installation. Si aplicas el CR inmediatamente, falla silenciosamente o el recurso se queda en un estado inconsistente.

# Esperar a que los CRDs estén registrados
kubectl wait --for=condition=Established \
  crd/installations.operator.tigera.io \
  --timeout=120s

Aplicar el Installation CR con eBPF

# calico-installation.yaml
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
  name: default
spec:
  calicoNetwork:
    ipPools:
      - name: default-ipv4-ippool
        blockSize: 26
        cidr: 10.244.0.0/16
        encapsulation: VXLANCrossSubnet
        natOutgoing: Enabled
        nodeSelector: all()
    linuxDataplane: BPF
    hostPorts: Enabled
  cni:
    type: Calico
  controlPlaneReplicas: 1
---
apiVersion: operator.tigera.io/v1
kind: APIServer
metadata:
  name: default
spec: {}
kubectl apply -f calico-installation.yaml

Con linuxDataplane: BPF, Calico opera enteramente en eBPF. Si tenías kube-proxy instalado (que no es el caso si seguiste los pasos anteriores), deberías parchar su DaemonSet para que no se programe:

# Solo si kube-proxy está instalado
kubectl patch ds -n kube-system kube-proxy \
  -p '{"spec":{"template":{"spec":{"nodeSelector":{"non-calico": "true"}}}}}'

Service Mesh: Istio Ambient Mode

Por qué ambient y no sidecar

El modo sidecar inyecta un proxy Envoy en cada pod. Funciona, pero tiene overhead: cada pod consume más memoria, el startup es más lento, y hay toda una categoría de bugs relacionados con el orden de inicio del sidecar vs. la aplicación.

Ambient mode cambia el modelo: un daemon ztunnel por nodo maneja mTLS y L4 automáticamente para todos los pods del namespace. Si necesitas funcionalidades L7 (retries, timeouts, circuit breaking, observabilidad HTTP), despliegas un waypoint proxy compartido por namespace. Solo pagas el coste de L7 donde lo necesitas.

Instalar Istio

istioctl install --set profile=ambient -y

Para habilitar ambient en un namespace de aplicación:

kubectl label namespace mi-app istio.io/dataplane-mode=ambient

Para añadir un waypoint proxy compartido (L7):

istioctl waypoint apply --namespace mi-app --enroll-namespace
Namespaces de infraestructura

No apliques ambient a namespaces de infraestructura como kube-system, calico-system, tigera-operator, istio-system, o monitoring. El service mesh es para tus workloads de aplicación. Los componentes de infraestructura tienen sus propios mecanismos de seguridad y meterlos en el mesh solo introduce problemas.

Ingress: Gateway API + cert-manager + MetalLB

Esta es la capa que hace que tus servicios sean accesibles desde internet con TLS automático. Cuatro componentes trabajando juntos.

MetalLB

En cloud, un Service de tipo LoadBalancer obtiene una IP externa del proveedor. En bare metal, necesitas MetalLB para eso.

helm repo add metallb https://metallb.github.io/metallb
helm repo update

helm install metallb metallb/metallb \
  --namespace metallb-system \
  --create-namespace

Espera a que los pods estén ready y luego configura el pool de IPs. En un solo servidor, el pool es la IP pública del servidor:

# metallb-config.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: default-pool
  namespace: metallb-system
spec:
  addresses:
    - 203.0.113.10/32  # Tu IP pública
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: default
  namespace: metallb-system
spec:
  ipAddressPools:
    - default-pool

cert-manager + ClusterIssuer

helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true

El ClusterIssuer usa DNS-01 con Cloudflare para poder emitir certificados wildcard:

# cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: tu-email@example.com
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token

Crea el Secret con el token de Cloudflare:

kubectl create secret generic cloudflare-api-token \
  --namespace cert-manager \
  --from-literal=api-token=TU_TOKEN_DE_CLOUDFLARE

Y el Certificate wildcard:

# wildcard-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-darkden-net
  namespace: istio-system
spec:
  secretName: wildcard-darkden-net-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - "darkden.net"
    - "*.darkden.net"

Istio Gateway

# gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: main-gateway
  namespace: istio-system
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  gatewayClassName: istio
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: All
    - name: https
      protocol: HTTPS
      port: 443
      tls:
        mode: Terminate
        certificateRefs:
          - name: wildcard-darkden-net-tls
      allowedRoutes:
        namespaces:
          from: All

HTTPRoute de ejemplo

# httproute-example.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: mi-app
  namespace: mi-app
spec:
  parentRefs:
    - name: main-gateway
      namespace: istio-system
      sectionName: https
  hostnames:
    - "app.darkden.net"
  rules:
    - backendRefs:
        - name: mi-app-svc
          port: 8080

External DNS

Para que los registros DNS se creen automáticamente cuando creas un HTTPRoute:

helm repo add external-dns https://kubernetes-sigs.github.io/external-dns
helm repo update

helm install external-dns external-dns/external-dns \
  --namespace external-dns \
  --create-namespace \
  --set provider.name=cloudflare \
  --set env[0].name=CF_API_TOKEN \
  --set env[0].valueFrom.secretKeyRef.name=cloudflare-api-token \
  --set env[0].valueFrom.secretKeyRef.key=api-token \
  --set "sources={gateway-httproute}" \
  --set policy=sync \
  --set registry=txt \
  --set txtOwnerId=k8s-cluster

Con esto, cada vez que creas un HTTPRoute con un hostname, External DNS crea el registro A en Cloudflare automáticamente. Cuando lo borras, lo limpia.

Datos: Operators de bases de datos

En un solo servidor puedes tener múltiples bases de datos gestionadas por operators de Kubernetes. No voy a entrar en la configuración de cada uno — cada operator merece su propio post — pero el punto es que el patrón funciona:

  • CloudNativePG — PostgreSQL nativo de Kubernetes. Backups automatizados, failover (en multi-nodo), WAL archiving. Para aplicaciones nuevas, es la elección por defecto.
  • MariaDB Operator — para aplicaciones que necesitan MySQL/MariaDB.
  • Redis Operator (Spotahome) — para caching, sessions, y colas.
  • Strimzi — Kafka para arquitecturas event-driven. Pesado en recursos, solo si realmente lo necesitas.
bash

$ kubectl get pods -A | grep -E ‘cnpg|mariadb|redis’

cnpg-system cnpg-controller-manager-5d7f9b4c8-x2k9l 1/1 Running databases mi-app-pg-1 1/1 Running databases mi-app-mariadb-0 1/1 Running databases redis-node-0 1/1 Running

Observabilidad: Grafana + Loki + Prometheus

kube-prometheus-stack

Un solo Helm chart que te da Prometheus, Grafana, Alertmanager, node-exporter, y un montón de dashboards preconfigurados:

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm install kube-prometheus prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  --create-namespace \
  --set grafana.adminPassword=TU_PASSWORD_SEGURO \
  --set prometheus.prometheusSpec.retention=30d \
  --set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage=50Gi

Grafana se expone vía HTTPRoute con autenticación. Configura Google SSO (o el proveedor que prefieras) en los values de Grafana para no depender de usuario/password.

Loki + Promtail

Para logs centralizados:

helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

helm install loki grafana/loki-stack \
  --namespace monitoring \
  --set promtail.enabled=true \
  --set loki.persistence.enabled=true \
  --set loki.persistence.size=20Gi

Dashboards recomendados

  • Node Exporter Full (ID 1860) — métricas del servidor: CPU, RAM, disco, red
  • Kubernetes / Pod Resources — consumo por pod y namespace
  • Trivy Operator Vulnerabilities (ID 17813) — vulnerabilidades detectadas en imágenes
  • Istio Mesh Dashboard — tráfico del service mesh, latencias, error rates

Seguridad: Defensa en profundidad

La seguridad no es un componente — es un stack de capas. Cada una cubre un ángulo diferente.

Trivy Operator

Escaneo automático de vulnerabilidades en todas las imágenes del cluster:

helm repo add aqua https://aquasecurity.github.io/helm-charts
helm repo update

helm install trivy-operator aqua/trivy-operator \
  --namespace trivy-system \
  --create-namespace \
  --set trivy.ignoreUnfixed=true

Trivy crea VulnerabilityReport CRDs por cada workload. Puedes consultarlos con kubectl y visualizarlos en el dashboard de Grafana.

OPA Gatekeeper

Policy enforcement para prevenir configuraciones peligrosas:

helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts
helm repo update

helm install gatekeeper gatekeeper/gatekeeper \
  --namespace gatekeeper-system \
  --create-namespace

Constraint templates que deberías tener desde el día uno:

  • Require resource limits — ningún pod sin requests/limits de CPU y memoria
  • Block latest tag — forzar tags inmutables en imágenes
  • Require labelsapp.kubernetes.io/name y app.kubernetes.io/version obligatorios
  • Block privileged containers — nadie corre como root a menos que sea explícitamente necesario

Falco

Detección de amenazas en runtime. Falco monitoriza syscalls y genera alertas cuando detecta comportamiento sospechoso (shell en contenedor, lectura de secrets, escritura en directorios sensibles):

helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update

helm install falco falcosecurity/falco \
  --namespace falco \
  --create-namespace \
  --set falcosidekick.enabled=true \
  --set falcosidekick.webui.enabled=true
Acceso a Falco

La UI de Falco Sidekick nunca se expone a internet. Accédela solo a través de Tailscale o port-forward. Contiene información sensible sobre el comportamiento interno de tu cluster.

Network Policies

Default-deny en cada namespace de aplicación, con allows explícitos:

# default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: mi-app
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
---
# allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: mi-app
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to: []
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
---
# allow-istio.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-istio
  namespace: mi-app
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: istio-system
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: istio-system

nftables para proteger el API Server

Calico eBPF y filtrado de IPs

No uses iptables con filtrado por IP de origen si tienes Calico eBPF. El procesamiento eBPF modifica las IPs de origen mediante DNAT antes de que lleguen a las reglas de iptables, así que tus reglas de filtrado por source IP nunca van a matchear correctamente. Filtra por interfaz de red con nftables.

# /etc/nftables.conf
table inet filter {
    chain input {
        type filter hook input priority filter; policy accept;

        # API Server: solo accesible desde localhost y Tailscale
        tcp dport 6443 iifname "lo" accept
        tcp dport 6443 iifname "tailscale0" accept
        tcp dport 6443 drop
    }
}
sudo systemctl enable nftables
sudo systemctl start nftables

Con esto, el API Server solo acepta conexiones desde el propio servidor (localhost) y desde la VPN de Tailscale. Todo lo demás se descarta.

Gestión: KEDA, Reloader, ESO

Tres herramientas que simplifican la operación diaria:

KEDA (Kubernetes Event-Driven Autoscaling) — escala pods basándose en métricas custom, longitud de colas, rate de peticiones, o cualquier fuente de datos. Más flexible que el HPA nativo.

helm repo add kedacore https://kedacore.github.io/charts
helm repo update

helm install keda kedacore/keda \
  --namespace keda \
  --create-namespace

Reloader — monitoriza ConfigMaps y Secrets. Cuando cambian, hace rolling restart automático de los Deployments que los referencian. Sin Reloader, cambiar un ConfigMap requiere reiniciar pods manualmente.

helm repo add stakater https://stakater.github.io/stakater-charts
helm repo update

helm install reloader stakater/reloader \
  --namespace reloader \
  --create-namespace

External Secrets Operator — sincroniza secrets desde backends externos (Vault, Infisical, AWS Secrets Manager) hacia Kubernetes Secrets. Preparado para cuando quieras centralizar la gestión de secrets fuera del cluster.

helm repo add external-secrets https://charts.external-secrets.io
helm repo update

helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets \
  --create-namespace

El resultado final

Con todo instalado, esto es lo que tienes corriendo:

CapaComponentes
Runtimecontainerd, kubelet, kubeadm
NetworkingCalico eBPF (CNI + reemplazo de kube-proxy)
Service MeshIstio ambient (ztunnel + waypoints)
IngressGateway API + MetalLB + External DNS
TLScert-manager + Let’s Encrypt + Cloudflare DNS-01
DatosCloudNativePG, MariaDB Operator, Redis Operator
ObservabilidadPrometheus, Grafana, Alertmanager, Loki, Promtail
SeguridadTrivy, Gatekeeper, Falco, Network Policies, nftables
GestiónKEDA, Reloader, External Secrets Operator
bash

$ kubectl get pods -A —field-selector status.phase=Running -o json | jq ‘.items | length’ 47

47 pods corriendo en un solo servidor. El consumo base del stack de infraestructura ronda los 4-5GB de RAM y 1.5-2 vCPUs. El resto está disponible para tus workloads.

Desplegar una app nueva es:

# Crear namespace y habilitarlo para el service mesh
kubectl create namespace mi-nueva-app
kubectl label namespace mi-nueva-app istio.io/dataplane-mode=ambient

# Desplegar el waypoint si necesitas L7
istioctl waypoint apply --namespace mi-nueva-app --enroll-namespace

# Aplicar manifests (Deployment, Service, HTTPRoute)
kubectl apply -f mi-nueva-app/

DNS, TLS, métricas, logs, network policies, escaneo de vulnerabilidades — todo ya está ahí esperando.

Conclusión

Este setup no es un homelab de juguete. Es el mismo patrón arquitectónico que usarías en producción cloud, adaptado a un solo nodo. La diferencia de coste es brutal: 15-30€/mes por un servidor dedicado vs. cientos de euros en un cluster gestionado con el mismo stack.

Los gotchas están en los detalles que no aparecen en la documentación oficial:

  • CRDs que no se registran a tiempo y causan fallos silenciosos en los operators
  • eBPF que rompe el filtrado por source IP en iptables
  • Istio init containers que interfieren con operators de bases de datos que necesitan inicializar el sistema de archivos antes de que la red esté disponible
  • containerd sin SystemdCgroup que causa reinicios aleatorios de pods

Cada uno de estos te puede costar horas de debugging si no los conoces de antemano. Ahora los conoces.