La gente siempre me pregunta por qué no usé simplemente WordPress, o Ghost, o sinceramente cualquiera de la docena de plataformas que me habrían hecho publicar un primer post en menos de una hora en vez de tardar un año. La respuesta corta es que quería aprender algo, y la respuesta larga es básicamente todo este post.
lo que realmente probé primero
Sí miré las opciones obvias antes de decidir no usarlas. WordPress se sentía como la herramienta equivocada para lo que realmente quería, mucha superficie de plugins para cosas que no necesitaba, y un stack de PHP que no tenía ningún interés en aprender solo para llevar un blog. Ghost estaba más cerca, genuinamente bueno recién instalado, pero sigue siendo la plataforma de otra persona por debajo, y todo el punto de este proyecto nunca fue realmente "tener un blog", era "construir algo lo bastante real como para que no pudiera fingir que sabía hacerlo". Una plataforma alojada no te pide entender cómo funciona el login, o cómo se guardan los archivos que subes, o qué pasa cuando doscientas personas visitan la misma página a la vez. Yo quería la cosa que hace esas preguntas, no la cosa que las responde por mí antes de que se me ocurra preguntarlas.
Llevaba un tiempo aprendiendo trocitos de Go y SvelteKit, sobre todo con scripts pequeños y proyectos de tutorial a medio terminar, y nada de eso se sentía como que sumara a algo real. Un blog parecía una buena excusa. Suficientemente pequeño para terminarlo de verdad, con suficientes piezas reales (cuentas, una base de datos, algo de cara al público) como para que no pudiera fingir que sabía hacerlo. Así que en vez de instalar algo que ya funcionaba, decidí construir lo que más me enseñaría, aun sabiendo que me llevaría mucho más tiempo.
la forma en la que terminó convirtiéndose
Cinco servicios, un archivo docker compose, corriendo en una sola máquina en mi piso. Nada de eso era el plan el primer día. Es solo donde terminaron un montón de decisiones pequeñas, la mayoría tomadas porque el enfoque anterior se rompió.
services:
postgres:
auth:
blog:
frontend:
caddy:por qué Go y SvelteKit específicamente, y no algo más popular
Ninguna de las dos elecciones fue realmente sobre escoger "la mejor" herramienta. Go, porque quería algo compilado y aburrido en el buen sentido, un lenguaje que no tiene quince formas distintas aceptadas de estructurar un servicio web pequeño, y porque la standard library sola te lleva la mayor parte del camino hasta un servidor HTTP funcional sin tener que recurrir a un framework por defecto. SvelteKit, sobre todo porque era lo más nuevo de mi lista en ese momento y quería que la mitad del frontend de este proyecto fuera también un proyecto de aprendizaje, no solo un sitio donde enchufar una herramienta conocida encima de los dos servicios en Go. Django o Express me habrían llevado a un sitio funcional más rápido, ambos lenguajes que ya conocía razonablemente bien de antes. Ser más rápido no era realmente el objetivo aquí.
Docker compose por encima de algo más elaborado fue una decisión igual de poco glamurosa. Esto corre en una sola máquina. Kubernetes resuelve problemas que no tengo, coordinar muchas máquinas, hacer deploys sin downtime en toda una flota, nada de eso aplica a un solo servidor sentado en mi piso. Un archivo compose que puedo leer de principio a fin en menos de un minuto le ganó a una configuración más "seria" que me habría tomado semanas aprender sin ningún beneficio real a esta escala.
el servicio de auth, en palabras simples
Un servicio pequeño en Go, un solo trabajo: saber quién eres, y demostrárselo a todo lo demás sin que nadie más tenga que confiar directamente en una contraseña. Regístrate, inicia sesión, recibe un access token de corta duración más una cookie de refresh de más larga duración, así te mantienes conectado sin que el access token en sí viva para siempre si alguna vez se filtra.
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(w, r, &req); err != nil {
writeDecodeError(w, r, err)
return
}
user, err := s.Queries.GetUserByUsername(r.Context(), req.Username)
if err != nil {
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
ok, _ := password.Verify(req.Password, user.PasswordHash)
if !ok {
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
s.issueTokensAndRespond(w, r, user, http.StatusOK)
}Las contraseñas nunca se guardan directamente, obviamente, solo un hash, y un hash específico diseñado para ser lento a propósito (argon2id), así que aunque la base de datos se filtrara alguna vez, hacer fuerza bruta para recuperar las contraseñas reales sale lo bastante caro como para que no valga la pena intentarlo a gran escala.
el servicio de blog, en palabras simples
El segundo servicio en Go, uno completamente separado, con su propia base de datos, es dueño de los posts, comentarios, tags, categorías, metadatos de medios. No sabe nada de contraseñas por sí mismo, solo confía en un token que emitió el servicio de auth, de la misma forma que un portero confía en una pulsera sin necesitar verificar tu identificación otra vez en persona en cada puerta dentro del local.
r.Route("/admin/posts", func(r chi.Router) {
r.Use(authmw.RequireCapability(verifier, matrix, capability.PostsManage))
r.Get("/", s.AdminListPosts)
r.Post("/", s.AdminCreatePost)
r.Patch("/{id}", s.AdminUpdatePost)
})Mantener esto como un segundo servicio separado del de auth se sentía como exagerado al principio, saltos de red extra sin razón obvia, hasta que realmente quise razonar sobre "qué puede salir mal con el sistema de cuentas" completamente separado de "qué puede salir mal con el sistema de posts". Dos problemas más pequeños y aburridos en vez de uno grande y aterrador.
el frontend en SvelteKit, en palabras simples
Esta es la parte que cualquiera que visita el sitio realmente ve, páginas renderizadas en el servidor, un formulario de login, el editor de posts, todo. No habla directamente con los dos servicios en Go desde el navegador, pasa todo primero por sus propias rutas del lado del servidor, algo que resultó importar mucho más de lo que esperaba una vez que llegué de verdad a las luchas con el reverse proxy y el rate limiting más abajo.
async function handle(event: RequestEvent): Promise<Response> {
const upstreamPath = event.url.pathname.replace(/^\/api/, '') || '/';
const target = new URL(upstreamPath + event.url.search, env.BLOG_INTERNAL_URL);
const upstream = await forwardRequest(event, target, extraHeaders);
return new Response(upstream.body, { status: upstream.status });
}postgres, en palabras simples
Dos bases de datos separadas, una para auth, una para el blog, no un esquema compartido con todo mezclado. Se sintió como más configuración sin razón al principio, y después se sintió obviamente correcto la primera vez que quise respaldar o migrar una sin tocar la otra para nada.
minio, en palabras simples
Las imágenes subidas y los avatares no viven dentro de Postgres, viven en MinIO, que habla la misma API que Amazon S3 pero corre como un contenedor más en mi propia máquina. Me gustó esto específicamente porque significa que cambiar a un bucket de nube real más adelante, si alguna vez lo necesitara, es un cambio de configuración y no una reescritura. Si alguna vez voy a necesitarlo de verdad es otra pregunta aparte. Tener la opción no costó casi nada de entrada.
caddy, en palabras simples
Lo único de todo el stack con un puerto público. Todo lo demás solo habla con lo demás por la red interna de docker, nada alcanzable directamente desde fuera excepto a través de Caddy. Termina el TLS, así que nunca tengo que pensar en la renovación de certificados, y es lo que realmente aplica cada límite de tamaño y cada rate limit del que el resto de este post está a punto de quejarse por haberlo configurado mal la primera vez.
handle /api/admin/* {
request_body {
max_size 5MB
}
reverse_proxy frontend:3000
}Esa es más o menos la forma de todo. Ahora las partes que realmente salieron mal, cada una merece su propia sección honesta en vez de un único párrafo vago de "y luego arreglé algunos bugs".
la saga del límite de tamaño en las subidas
Las subidas de archivos fueron el primer muro real. Podía subir una imagen de prueba pequeña sin problema, luego probé con una foto real directo desde el móvil y me dio un error críptico sin ningún mensaje útil, solo una petición fallida sin nada útil en la consola del navegador. Resultó que hay un límite de tamaño de petición en Caddy delante del límite propio de la app, y yo lo había puesto demasiado pequeño sin entender realmente que dos capas separadas tenían que estar de acuerdo en qué significa "demasiado grande".
# before: media uploads fell under the same small default as everything else
handle /api/admin/* {
request_body {
max_size 2MB
}
reverse_proxy frontend:3000
}El valor por defecto de Caddy no se acerca ni de lejos a ser lo bastante generoso para una foto moderna de móvil, y hasta que realmente fui a buscarlo, no tenía ni idea de que fuera siquiera un límite separado del que el propio servicio en Go aplicaba.

Me llevó una tarde entera mirando logs confundido antes siquiera de encontrar dónde vivía el límite, y no digamos ya por qué. Media solución vino de un hilo de foro de hace cinco años, no de la documentación, alguien describiendo exactamente el mismo síntoma en un proyecto completamente distinto, que es como realmente se me ocurrió ir a mirar la configuración de Caddy en vez de asumir que el bug vivía en mi propio código Go todo el tiempo.
handle /api/admin/media* {
request_body {
max_size 1100MB
}
reverse_proxy frontend:3000
}Un límite separado, mucho más grande, solo para la ruta de subida de medios específicamente, todo lo demás en la app se queda con el valor por defecto más pequeño. En cuanto las dos capas por fin estuvieron de acuerdo entre sí, las fotos reales empezaron a subir sin quejarse.
comentarios y login tardando una eternidad, más de lo que esperaba
Creo que asumí que "el usuario escribe la contraseña, el servidor la comprueba" era básicamente toda la funcionalidad. Realmente hay una cantidad sorprendente de cosas por debajo: hashear la contraseña correctamente, emitir un token de corta duración más otro de más larga duración para mantener la sesión, y luego la parte que de verdad me atrapó, el rate limiting detrás de un reverse proxy. Los comentarios resultaron tener su propia versión más pequeña de la misma lección, un formulario de comentarios parece trivial hasta que también estás manejando spam, rate limits por usuario, y moderación, nada de lo cual aparece en el ejemplo de "añade una caja de comentarios" de un tutorial.
aprendiendo qué hace realmente un reverse proxy
Cada intento de login parecía venir de la misma IP, porque Caddy era lo único que hablaba directamente con mi app, y yo no le había dicho que confiara en el header que en realidad dice quién es el visitante real. Eso tardó bastante en siquiera entenderse como un bug, porque el síntoma parecía "el rate limiting está roto" en vez de "en realidad no entiendo qué le hace un reverse proxy a una petición". Reverse proxy es uno de esos términos que había usado constantemente durante años sin saber realmente qué hacía mecánicamente, y construir esto me hizo sentarme de verdad a entenderlo en vez de simplemente asentir la próxima vez que alguien mencionara uno.

el rate limiting mordiéndome específicamente a mí
Una vez que entendí el problema del reverse proxy, la solución real fue pequeña, confiar en exactamente un salto del header forwarded-for, coincidiendo con el único proxy que realmente tengo delante de la app. Pero llegar hasta ahí significó primero notar que diez intentos de login fallidos de diez personas reales distintas de alguna forma estaban cayendo todos en el mismo cubo, sospechar que algo estaba fundamentalmente mal en cómo identificaba de dónde venía en realidad una petición, y solo entonces darme cuenta de que la IP de origen aparente de la petición era la dirección del contenedor de Caddy cada vez, para cada visitante, porque Caddy realmente es lo único que abre un socket hacia el frontend. Que todos los visitantes compartan una sola identidad, desde el punto de vista de la app, es tan roto como puede llegar a estar el rate limiting sin llegar a lanzar un error de verdad que te avise.
el rediseño, genérico primero, luego de verdad Lovund
La primera versión real del diseño de este sitio era, visto en retrospectiva, una plantilla de blog con los números de serie limados. Color de acento azulado, fuente de sistema por defecto, espaciado que se parecía al de cualquier otro sitio rápido que alguien monta en un fin de semana. Funcionaba. También podría haber sido el blog de cualquiera, algo que empezó a molestarme cada vez más cuanto más lo miraba, una vez que la funcionalidad en sí ya era lo bastante sólida como para que el diseño fuera lo único que quedaba destacando como inacabado.
Hace unas semanas rehice todo el lado visual. Color de acento verde, una tipografía serif para el cuerpo del texto, márgenes más ajustados que el valor por defecto con el que había empezado. No porque la versión anterior estuviera rota. Se parecía a cualquier otra plantilla rápida de blog, y quería algo que se sintiera como una elección real en vez de lo que trae de fábrica un starter kit. (También dije que no volvería a tocar el CSS una vez que el rediseño saliera. Eso duró como cuatro días.)
El verde en concreto no fue al azar. Vivir en un sitio tan verde como se pone Lovund en verano, musgo y ladera y el agua haciendo ese color profundo tan particular bajo un cielo nublado, se sintió como el acento que este sitio en realidad debería haber tenido desde el principio en vez de cualquier color azulado genérico al que había recurrido por defecto sin pensarlo. En cuanto elegí eso, el resto del rediseño básicamente vino solo de hacer todo lo demás lo bastante callado como para que el verde de verdad destacara en vez de competir con otros seis colores por la atención.
una funcionalidad pequeña que añadí después
Una cosa que hace el servicio de blog que originalmente no planeé: publicación programada. Un post puede quedarse como borrador con una hora de publicación en el futuro, y un job en segundo plano revisa cada treinta segundos si a algo ya le llegó su hora, lo pone en vivo, y sella el timestamp real de publicación en ese momento en vez de cuando yo diera clic en guardar. Es un mecanismo pequeño, un bucle que hace tick y dos sentencias SQL por debajo, nada que necesitara una cola de trabajos ni nada más elaborado. Lo construí sobre todo para poder escribir varios posts de una sentada en una noche tranquila y no que todos se publicaran a la vez, espaciándolos en su lugar sin tener que acordarme de volver y darle a publicar en cada uno individualmente.
las realidades del self-hosting, porque esa parte tampoco es gratis
Manejar esto yo mismo en vez de pagar una plataforma significa que cada caída es mía para notarla y mía para arreglarla, según mi propio horario, normalmente descubierta por mí mismo intentando revisar algo en vez de por cualquier tipo de alerta. Los backups importan más aquí de lo que importarían en una plataforma administrada, ya que no hay ningún proveedor guardando en silencio una copia de mi base de datos en algún sitio del que no tenga que preocuparme. Tengo ambas bases de datos de Postgres volcándose cada noche y subidas a algún sitio fuera de esta máquina, porque una configuración de una sola máquina también es un único punto de fallo, y descubrir eso durante un fallo de disco real habría sido una manera genuinamente mala de aprender la lección.
Lo que de verdad se rompe, en la práctica, es más pequeño y más aburrido de lo que esperaba al empezar: un contenedor de vez en cuando necesita reiniciarse después de un reinicio del host que se me olvidó tener en cuenta, el espacio en disco sube más despacio de lo que había supuesto pero sube igual, y el único susto real hasta ahora fue un problema de permisos en el directorio de datos de Postgres después de mudarme a un almacenamiento nuevo, que me tomó una tarde arreglar y me alegró bastante que los backups de verdad existieran en vez de ser algo que había pensado probar y nunca probé.
Reiniciar un servicio es una cosa cuando la máquina en sí está a unos metros de mí. Es una sensación completamente distinta saber que si toda la máquina muriera del todo, el plan real de recuperación es una máquina nueva, un volcado de base de datos restaurado, y el tiempo que sea que lleve volver a levantar docker compose desde cero, probablemente la mayor parte de una tarde si todo lo que he anotado sobre la configuración es realmente exacto y nada ha ido cambiando en silencio desde la última vez que lo revisé. Todavía no he probado ese escenario completo, que es exactamente el tipo de cosa que sé que debería hacer en un fin de semana tranquilo en vez de descubrirlo por las malas durante una caída real.
qué sigue
Herramientas de moderación de comentarios de verdad, ya que ahora mismo lo hago a mano directamente en la base de datos, lo cual está bien a la escala diminuta actual pero no se va a quedar bien. Escribir de verdad más, ahora que la excusa de "construir la plataforma" para no escribir nada se ha acabado en buena parte. Y probablemente, en algún momento, ir de verdad a escalar una de las montañas que sigo mirando desde el ferry en vez de solo escribir sobre las ganas de hacerlo.