Cómo reconstruí mi blog para que funcionara con Flask (toda la historia técnica completa)

Este es el segundo primer post de mi blog. Primero porque es el primero que aparece, pero segundo porque hubo otro antes, pero dadas las caracterísitcas de lo que tuve que hacer, el otro era inservible y por eso que este sesa el segundo primer post. En este documento quiero dejar por escrito todo el proceso técnico real que he seguido para reconstruir mi blog y convertirlo en una aplicación web completa usando Flask, Gunicorn, Nginx, Markdown dinámico, plantillas, rutas, permisos, venv y servicios systemd.

Este post es largo, técnico y detallado. Es exactamente lo que me habría gustado leer antes de empezar con este lío, así que espero poder inspirar a alguien. Si no, que al menos sirva como entretenimiento o motivación para que le deis una segunda vida a un ordenador viejo!


1. Estructura profesional del proyecto

Lo primero fue reorganizar todo mi servidor para que Flask pudiera servir la web completa. La estructura final quedó así:

/var/www/main_web/ app.py venv-web/ templates/ index.html plantilla.html blog.html static/ css/ estilos.css estilos_blog.css estilos_main.css img/ posts/ md/ post1.md post2.md html/

Permisos

Para que Nginx y Gunicorn puedan leer todo:

sudo chown -R serherm:www-data /var/www/main_web sudo chmod -R 755 /var/www/main_web

2. Entorno virtual (venv)

Para aislar Flask y sus dependencias:

python3 -m venv venv-web source venv-web/bin/activate pip install flask markdown gunicorn

3. El corazón del sistema: app.py

Aquí es donde Flask toma el control de toda la web.

Rutas principales

/ → página principal /blog → índice del blog /blog/posts/ → muestra un post Markdown convertido a HTML

Conversión de Markdown a HTML

import markdown from flask import Flask, render_template

app = Flask(name)

@app.route("/blog/posts/") def mostrar_post(post): ruta_md = f"/var/www/main_web/posts/md/{post}.md"

with open(ruta_md, "r", encoding="utf-8") as f:
    contenido_md = f.read()

contenido_html = markdown.markdown(contenido_md)

return render_template("plantilla.html",
                       titulo=post,
                       contenido=contenido_html)

4. Plantillas HTML con Jinja2

Flask usa Jinja2, así que puedo mezclar HTML con variables.

La plantilla del post (plantilla.html)

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>{{ titulo }}</title>
<link rel="stylesheet" href="/static/css/estilos.css">
</head>

<body>
<header class="header">
<div class="logo">serblog</div>
<nav class="nav">
<a href="/" class="nav-btn">Inicio</a>
<a href="/blog" class="nav-btn">Blog</a>
</nav>
</header>

<main class="post-container">
<h1 class="post-title">{{ titulo }}</h1>
<article class="post-content">
{{ contenido|safe }}
</article>
</main>

<footer class="footer">
Sergio © 2026 ®
</footer>
</body>

</html>

5. Sistema de posts en Markdown

Cada post es un archivo .md dentro de:

/var/www/main_web/posts/md/

Ejemplo:

# Título del post
Contenido en Markdown...

Flask lo convierte a HTML en tiempo real, así que no necesito generar archivos HTML.

6. Nginx: el guardián del servidor

Nginx se encarga de:

Mi configuración final

server { listen 80; server_name serblog.hopto.org; return 301 https://$host$request_uri; }

server { listen 443 ssl; server_name serblog.hopto.org;

ssl_certificate /etc/ssl/certs/servidor.crt;
ssl_certificate_key /etc/ssl/private/servidor.key;

root /var/www/main_web;
index index.html;

location /static/ {
    alias /var/www/main_web/static/;
}

location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

}

7. Gunicorn: el servidor WSGI

Gunicorn es el servidor que ejecuta Flask en producción. Flask por sí solo no es apto para producción, así que Gunicorn se encarga de:

Servicio systemd

Archivo:

/etc/systemd/system/main_web.service

Contenido:

[Unit] Description=Gunicorn service for main_web After=network.target

[Service] User=serherm Group=www-data WorkingDirectory=/var/www/main_web Environment="PATH=/var/www/main_web/venv-web/bin" ExecStart=/var/www/main_web/venv-web/bin/gunicorn -w 4 -b 127.0.0.1:8000 app:app

[Install] WantedBy=multi-user.target

Significado técnico de cada parámetro

User=serherm
Gunicorn se ejecuta como mi usuario, no como root.

Group=www-data
Permite que Nginx (que usa www-data) acceda a los archivos.

WorkingDirectory
Directorio donde está app.py.

Environment="PATH=..."
Obliga a systemd a usar el Python del venv.

ExecStart
Comando real que lanza Gunicorn:

gunicorn -w 4 -b 127.0.0.1:8000 app:app

Comandos para controlar el servicio

sudo systemctl start main_web
sudo systemctl stop main_web
sudo systemctl restart main_web
sudo systemctl status main_web

Reinicio limpio (muy importante)

Cuando cambio plantillas o código:

sudo systemctl stop main_web
pkill gunicorn
sudo systemctl start main_web

Esto garantiza que todos los workers cargan la versión nueva del código.

8. El problema más raro que he tenido (y cómo lo resolví)

Durante el desarrollo, me encontré con un bug extraño:

La causa final fue:

Chrome estaba usando una versión antigua del HTML en caché.

La solución:

Ctrl + Shift + R
Ctrl + F5
modo incógnito
reinicio limpio de Gunicorn

9. Conclusión

Ahora mi blog funciona con:

Este post resume todo el camino técnico real que he seguido para reconstruir mi blog y convertirlo en una aplicación web completa.

10. Próximos pasos