En mis casi seis años trabajando en Mercado Libre aprendí y crecí como profesional de una forma que no lo había hecho antes. Como todo aprendizaje eso ya forma parte de uno y te acompaña adonde vayas. Pero hay cosas que uno no se lleva. De esas, la que más había naturalizado y que la realidad de trabajar en una startup se encargó de demostrarme, fue Fury.
Fury es la plataforma interna de Mercado Libre para crear y operar microservicios: CI/CD, secrets, observabilidad y catálogo. Todo eso ya resuelto por la plataforma. Vos creás el servicio, la plataforma se encarga del resto.
En ese contexto también oculta, se trabaja a otro nivel de abstracción. En casi cualquier startup los niveles de abstracción son de pequeños a nulos. Un comportamiento anómalo te puede tener rápidamente en un war room frente a Datadog, traces, logs, un nodo y una DB a tu disposición para diagnosticar y estabilizar una situación. Nunca nadie está solo y hoy además se suman los superpoderes que nos da un LLM.
Pero justamente por eso, entender qué está sucediendo under-the-hood creo que es más valioso que nunca. Un LLM te puede sugerir un diagnóstico, tener un nivel de entendimiento de la situación te permite confirmarlo o darte cuenta cuando está equivocado y ahorrarte preciados minutos persiguiendo falsas hipótesis.
En 2017 compré un Intel NUC: i7, 16GB de RAM para aprender armando un home server de datos y deployar toy applications propias: Jenkins, SonarQube, Docker, MySQL. Esto fue aprendizaje propio, entender qué está sucediendo, cómo se conectan los engranajes. Aquí una guía dividida en diferentes partes acerca del viaje para crear otro tipo de monstruo, un Poor Man’s Fury con herramientas open source y presupuesto cero.

Fase 1 - La base: Ubuntu Server, K3s, Traefik
La base es Ubuntu Server 26.04, instalación mínima, con SSH habilitado desde el instalador. IP fija vía netplan:
# vim /etc/netplan/0-installer-config.yaml
network:
version: 2
ethernets:
eno1:
dhcp4: no
addresses:
- 192.168.100.100/24
routes:
- to: default
via: 192.168.100.1
nameservers:
addresses: [8.8.8.8, 1.1.1.1]
Firewall básico:
sudo apt install ufw -y
sudo ufw allow OpenSSH
sudo ufw allow 6443/tcp # K3s API
sudo ufw allow 443/tcp # Traefik HTTPS
sudo ufw allow 80/tcp # Traefik HTTP
sudo ufw enable
K3s, sin el Traefik que trae embebido por default (para instalar aparte con más control):
K3s es una distribución liviana de Kubernetes hecha por Rancher: mismo API y compatibilidad, pero con un binario único, sin componentes que en un clúster pequeño no hacen tanto sentido (etcd por defecto reemplazado por SQLite, sin cloud controller manager, etc.) pensada para edge/IoT y setups chicos.
curl -sfL https://get.k3s.io | sh -s - \
--disable traefik \
--write-kubeconfig-mode 644
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $USER:$USER ~/.kube/config
kubectl get nodes
Namespaces por scope, la misma idea de aislamiento por ambiente que tenía Fury, en miniatura:
kubectl create namespace fury-infra
kubectl create namespace fury-monitoring
kubectl create namespace fury-dev
kubectl create namespace fury-staging
kubectl create namespace fury-prod
kubectl label namespace fury-dev fury/scope=dev fury/env=test
kubectl label namespace fury-staging fury/scope=staging fury/env=demo
kubectl label namespace fury-prod fury/scope=prod fury/env=production
Traefik vía Helm como LoadBalancer. K3s trae su propio service-lb, no hace falta nada externo:
Traefik = ingress controller/reverse proxy para k8s. Se integra nativo con la CRD de k8s (menos YAML), tiene dashboard propio para debuggear rutas, y hace service discovery automático, cero reload manual cuando se agrega un servicio nuevo.
helm repo add traefik https://helm.traefik.io/traefik
helm repo update
cat > traefik-values.yaml << 'EOF'
deployment:
replicas: 1
service:
type: LoadBalancer
ingressRoute:
dashboard:
enabled: true
additionalArguments:
- "--api.insecure=true"
- "--log.level=INFO"
EOF
helm install traefik traefik/traefik \
--namespace kube-system \
--values traefik-values.yaml
Nota de versión: los
IngressRoutede Traefik migraron su CRD detraefik.containo.us/v1alpha1atraefik.io/v1alpha1. Si copiás YAMLs de guías más viejas y te tirano matches for kind "IngressRoute"seguramente es por esto. Podés confirmar con la versión que tenés con:kubectl get crd | grep traefik.
Fase 2 - CI/CD con runner self-hosted
Decisión de diseño: GitHub Actions con un runner propio en el NUC, no Gitea/Woodpecker por ejemplo. Dos razones: repos actualmente en GitHub e intención de mantenerlos ahí y el runner hace polling saliente, cero puertos entrantes que abrir.
Instalar el runner
gh auth login # scopes: repo, workflow, read:org
mkdir -p ~/actions-runner && cd ~/actions-runner
RUNNER_VERSION=2.335.1
curl -o actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz -L \
https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
tar xzf actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
./config.sh --url https://github.com/TU_ORG --token TOKEN_DE_GITHUB \
--labels self-hosted,linux,x64,nuc
sudo ./svc.sh install
sudo ./svc.sh start
Issue 01: si corrés
config.shsin--labelsexplícito (el wizard interactivo no lo pide), el runner queda solo con las labels autodetectadas (self-hosted,linux,x64), sinnuc. Si tu workflow pideruns-on: [self-hosted, nuc], el job se queda enQueuedpara siempre, sin error visible. Se agrega la label después desde GitHub > org > Settings > Actions > Runners.
Issue 02: si el repo es público, GitHub bloquea por default que runners self-hosted tomen esos jobs (un PR malicioso podría ejecutar código en tu hardware). Mismo síntoma:
Queuedeterno. Con un proyecto personal, lo más simple es pasar el repo a privado.
El pipeline
# .github/workflows/release.yml
name: Release Process
on:
pull_request:
branches: [main]
push:
branches: [main]
env:
IMAGE_API: ghcr.io/TU_ORG/TU_APP-api
jobs:
lint:
runs-on: [self-hosted, nuc]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: false
- run: go vet ./...
- run: |
fmt_out=$(gofmt -l .)
[ -n "$fmt_out" ] && { echo "$fmt_out"; exit 1; } || true
- uses: golangci/golangci-lint-action@v9
with:
version: v2.5
test:
runs-on: [self-hosted, nuc]
needs: lint
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: false
- env:
CGO_ENABLED: 1
run: go test ./... -race -cover
build-and-push:
runs-on: [self-hosted, nuc]
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.api
push: true
tags: |
${{ env.IMAGE_API }}:${{ github.sha }}
${{ env.IMAGE_API }}:latest
Esta es la versión que quedó funcionando. Algunos issues en el camino:
go-version-fileen lugar de un número fijo. La primera versión teníago-version: '1.22'hardcodeado, mientras elgo.modreal pedía>= 1.25.6. Leer la versión directo delgo.modevita esta desincronización.
golangci-lintpinneado av2.5. El binario default que instala la action (v1.64.8) está compilado con una versión de Go más vieja que la del proyecto y golangci-lint no analiza código de una versión de Go más nueva que la propia. El error es explícito en este caso:the Go language version used to build golangci-lint is lower than the targeted Go version.
CGO_ENABLED: 1solo en el step de test. El flag-racenecesitacgo, que viene apagado por default. Esto es puntual al test, no al build de la imagen, ahícgose mantiene apagado a propósito para generar un binario estático más liviano. Además, si tu base es Ubuntu Server mínimo, probablemente te faltegcc:sudo apt install build-essential.
docker/setup-buildx-actionexplícito. En runners hosteados por GitHub, buildx viene preinstalado, en self-hosted no. Y si instalaste Docker con el paquetedocker.iode Ubuntu (en vez del repo oficialdocker-ce), tampoco tenés el plugin, hay que sumarlo a mano:
mkdir -p ~/.docker/cli-plugins
curl -SL https://github.com/docker/buildx/releases/download/v0.34.1/buildx-v0.34.1.linux-amd64 \
-o ~/.docker/cli-plugins/docker-buildx
chmod +x ~/.docker/cli-plugins/docker-buildx
Verificar
docker login ghcr.io -u TU_USER --password-stdin <<< $(gh auth token)
docker pull ghcr.io/TU_ORG/TU_APP-api:latest
Si baja la imagen sin error, el circuito completo: push > lint > test > build > publish, está cerrado.
Dónde quedamos
El runner quedó corriendo como servicio, sobreviviendo reboots. Pipeline en verde en cada push a main, imagen real publicada en GitHub Container Registry. Nada de esto tiene forma segura de manejar un secret aún, eso es parte de los próximos pasos.