DEV Community

Cover image for Cómo construir y desplegar un dashboard de datos con Streamlit, GitHub Actions y Streamlit Community Cloud
JhonyVargas
JhonyVargas

Posted on

Cómo construir y desplegar un dashboard de datos con Streamlit, GitHub Actions y Streamlit Community Cloud

Casi todos los tutoriales de dashboards terminan en streamlit run app.py y una captura de pantalla. El problema es que ahí no hay nada desplegado, nada probado y nada automatizado: es una demo local que muere cuando cierras la terminal.

En este artículo construyo un dashboard real y lo llevo hasta el final: código en un repositorio público, tests que corren solos en cada push, y la app publicada en una nube pública que se redespliega sola. Sin pasos manuales de subida.


Qué vamos a construir

Un dashboard llamado Global Development Insights que explora tres indicadores de desarrollo — esperanza de vida, PBI per cápita y población — para ~140 países entre 1952 y 2007.

Tiene KPIs agregados, un mapa mundial, un bubble chart animado, rankings, series temporales y una matriz de correlación. Todo filtrable por año, continente y país.

Dashboard con los KPIs y el mapa mundial de esperanza de vida

El stack, y por qué

Pieza Herramienta Por qué
Framework Streamlit Convierte un script de Python en una web app. Cero HTML/CSS/JS.
Gráficos Plotly Express Interactivos por defecto (zoom, hover, animación) en una línea de código.
Datos Pandas El estándar para transformar datos tabulares en Python.
CI GitHub Actions Gratis en repos públicos, integrado al push.
Hosting Streamlit Community Cloud Gratis, y se conecta directo al repo de GitHub.

Una decisión clave: el dataset

Usé Gapminder, que viene incluido dentro de plotly.express:

import plotly.express as px
df = px.data.gapminder()
Enter fullscreen mode Exit fullscreen mode

Esto no es pereza, es una decisión de arquitectura. El dataset viaja dentro de una dependencia que ya instalo, así que:

  • No hay llamadas de red en tiempo de ejecución → la app no se cae porque una API de terceros esté lenta o caída.
  • No hay API keys → no hay secretos que gestionar en el CI ni en el hosting.
  • El CI es determinista → los tests no dependen de que internet funcione dentro del runner.

Si tu proyecto sí necesita una API externa, el consejo sigue valiendo: cachea la respuesta y ten un fallback, porque tu pipeline de CI va a fallar el día que la API tenga un mal día.


Arquitectura: separa los datos de la UI

Este es el punto que más diferencia hace y el que casi nadie aplica en los tutoriales.

La tentación con Streamlit es escribir un app.py de 300 líneas donde la carga de datos, los filtros, los cálculos y los gráficos están todos mezclados. Funciona... hasta que quieres testearlo. Y entonces descubres que no puedes, porque para ejecutar una sola línea necesitas levantar todo el runtime de Streamlit.

La solución es aburrida y efectiva: toda la lógica de datos vive en un módulo normal de Python, sin importar Streamlit.

app.py          # solo UI de Streamlit
src/data.py     # lógica de datos, testeable de forma aislada
tests/
Enter fullscreen mode Exit fullscreen mode

src/data.py no sabe que Streamlit existe:

def filter_data(
    df: pd.DataFrame,
    year: int | None = None,
    continents: list[str] | None = None,
    countries: list[str] | None = None,
) -> pd.DataFrame:
    filtered = df
    if year is not None:
        filtered = filtered[filtered["year"] == year]
    if continents:
        filtered = filtered[filtered["continent"].isin(continents)]
    if countries:
        filtered = filtered[filtered["country"].isin(countries)]
    return filtered


def compute_kpis(df_year: pd.DataFrame) -> dict:
    """KPIs agregados para un corte de un solo año."""
    if df_year.empty:
        return {"population": 0, "avg_life_exp": 0.0, "avg_gdp_percap": 0.0, "n_countries": 0}
    return {
        "population": int(df_year["pop"].sum()),
        "avg_life_exp": float(df_year["lifeExp"].mean()),
        "avg_gdp_percap": float(df_year["gdpPercap"].mean()),
        "n_countries": int(df_year["country"].nunique()),
    }
Enter fullscreen mode Exit fullscreen mode

Fíjate en el if df_year.empty de compute_kpis. Ese guard existe porque el usuario puede deseleccionar todos los continentes en la barra lateral. Sin él, .mean() sobre un DataFrame vacío devuelve NaN, y el dashboard muestra "nan años" en lugar de un número.

Es el tipo de bug que solo encuentras si pruebas los casos límite — y probarlos es fácil precisamente porque esta función no necesita Streamlit para ejecutarse.


La UI: filtros y pestañas

Con la lógica fuera, app.py queda declarativo. Los filtros van a la barra lateral:

selected_year = st.sidebar.select_slider("Año", options=years, value=years[-1])
selected_continents = st.sidebar.multiselect(
    "Continentes", options=continents, default=continents
)
Enter fullscreen mode Exit fullscreen mode

Los KPIs, en cuatro columnas:

df_year = filter_data(df, year=selected_year, continents=selected_continents)
kpis = compute_kpis(df_year)

col1, col2, col3, col4 = st.columns(4)
col1.metric("Países", kpis["n_countries"])
col2.metric("Esperanza de vida promedio", f"{kpis['avg_life_exp']:.1f} años")
col3.metric("PBI per cápita promedio", f"${kpis['avg_gdp_percap']:,.0f}")
col4.metric("Población total", f"{kpis['population']:,}")
Enter fullscreen mode Exit fullscreen mode

Y las visualizaciones se reparten en pestañas, lo que evita el scroll infinito:

tab_map, tab_bubble, tab_ranking, tab_trend, tab_corr = st.tabs(
    ["🗺️ Mapa mundial", "🫧 Bubble chart", "🏆 Rankings", "📈 Tendencias", "🔗 Correlaciones"]
)
Enter fullscreen mode Exit fullscreen mode

El mapa mundial es una sola llamada, porque Gapminder ya trae la columna iso_alpha con el código ISO de cada país:

with tab_map:
    fig_map = px.choropleth(
        df_year,
        locations="iso_alpha",
        color=map_metric,
        hover_name="country",
        color_continuous_scale="Viridis",
    )
    st.plotly_chart(fig_map, use_container_width=True)
Enter fullscreen mode Exit fullscreen mode

Y el bubble chart animado — el gráfico clásico de Hans Rosling — sale con el parámetro animation_frame:

fig_bubble = px.scatter(
    df_bubble,
    x="gdpPercap", y="lifeExp",
    size="pop", color="continent",
    hover_name="country",
    animation_frame="year",
    log_x=True, size_max=60,
    range_x=[df_bubble["gdpPercap"].min() * 0.8, df_bubble["gdpPercap"].max() * 1.2],
    range_y=[df_bubble["lifeExp"].min() - 5, df_bubble["lifeExp"].max() + 5],
)
Enter fullscreen mode Exit fullscreen mode

Bubble chart animado de PBI per cápita vs. esperanza de vida, con el control de reproducción por año

Dos detalles que importan aquí:

  • log_x=True, porque el PBI per cápita abarca varios órdenes de magnitud y en escala lineal todos los países pobres se apelotonan contra el eje.
  • Fijar range_x y range_y explícitamente. Si no lo haces, los ejes se reescalan en cada frame de la animación y el movimiento deja de ser comparable entre años — que es justo lo que el gráfico intenta mostrar.

Tests: dos niveles

Nivel 1 — la lógica de datos. Tests de pytest normales, rápidos, sin Streamlit:

def test_filter_data_by_year():
    df = load_data()
    filtered = filter_data(df, year=2007)
    assert (filtered["year"] == 2007).all()
    assert not filtered.empty
Enter fullscreen mode Exit fullscreen mode

Nivel 2 — la app entera. Streamlit trae un framework de testing propio, AppTest, que ejecuta el script y te deja inspeccionar y manipular los widgets sin abrir un navegador:

from streamlit.testing.v1 import AppTest

def test_app_runs_without_exceptions():
    at = AppTest.from_file(APP_PATH, default_timeout=60).run()
    assert not at.exception
    assert len(at.metric) == 4


def test_app_handles_empty_continent_selection():
    at = AppTest.from_file(APP_PATH, default_timeout=60).run()
    at.multiselect[0].set_value([]).run()
    assert not at.exception
Enter fullscreen mode Exit fullscreen mode

Ese último test es el que protege el caso límite del que hablé arriba: simula al usuario vaciando el filtro de continentes y verifica que la app no reviente. Es la clase de regresión que un test unitario de compute_kpis por sí solo no atrapa, porque el bug vive en la interacción entre el filtro y el render.


Automatización: el pipeline de CI

Aquí es donde el proyecto deja de ser un script y pasa a ser software. .github/workflows/ci.yml corre en cada push y cada pull request a main:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        run: pip install -r requirements-dev.txt

      - name: Lint with ruff
        run: ruff check .

      - name: Run unit tests
        run: pytest -q
Enter fullscreen mode Exit fullscreen mode

Tres etapas: lint → tests → smoke test. Las dos primeras son estándar. La tercera es la que recomiendo copiar:

      - name: Smoke test that the app boots
        run: |
          streamlit run app.py --server.headless true --server.port 8501 &
          for i in $(seq 1 15); do
            if curl -sf http://localhost:8501/_stcore/health; then
              echo "App is healthy"
              exit 0
            fi
            sleep 2
          done
          echo "App failed to become healthy in time"
          exit 1
Enter fullscreen mode Exit fullscreen mode

Levanta la app de verdad en modo headless y consulta /_stcore/health, el endpoint de salud que Streamlit expone. ¿Por qué molestarse, si ya hay tests?

Porque los tests unitarios pasan aunque la app no arranque. Un import roto, una dependencia que quedó fuera de requirements.txt, un error de sintaxis en una parte del script que ningún test toca — todo eso pasa el pytest y luego explota en producción. El smoke test responde a la única pregunta que de verdad importa antes de desplegar: ¿esto levanta?

Y es barato: unas líneas de bash y unos 10 segundos de runner.


Despliegue continuo: de push a producción

Streamlit Community Cloud es gratuito para repositorios públicos y se conecta directamente a GitHub. El flujo completo:

  1. Entra a share.streamlit.io con tu cuenta de GitHub.
  2. Create app → Deploy a public app from GitHub.
  3. Selecciona el repositorio, la rama main y el archivo principal (app.py).
  4. Elige el subdominio y dale a Deploy.

Streamlit lee requirements.txt, instala las dependencias y levanta la app. A partir de ahí, cada push a main la redespliega automáticamente — no hay build manual, ni subir archivos, ni un deploy.sh.

El resultado es la cadena completa:

git push → GitHub Actions (lint + tests + smoke) → Streamlit Cloud redespliega → app pública actualizada
Enter fullscreen mode Exit fullscreen mode

Un detalle de presentación: el tema

Streamlit permite versionar el aspecto de la app en .streamlit/config.toml, así que el tema viaja con el repo y no depende de la configuración de quien despliega:

[theme]
base = "light"
primaryColor = "#2563EB"
backgroundColor = "#FFFFFF"
secondaryBackgroundColor = "#F1F5F9"
textColor = "#0F172A"

[server]
headless = true
Enter fullscreen mode Exit fullscreen mode

Lo que me llevo

  • Separa la lógica de datos de la UI. Es una regla de una línea que marca la diferencia entre un proyecto testeable y uno que no lo es.
  • Prueba los estados vacíos. Todo filtro que el usuario puede vaciar es un NaN o un crash esperando a ocurrir.
  • Añade un smoke test al CI. Los tests verifican que tu código es correcto; el smoke test verifica que tu aplicación existe.
  • Elige datos sin dependencias de red cuando puedas. Un CI que depende de una API de terceros es un CI que falla por razones que no son tu culpa y que no puedes arreglar.
  • Automatiza el despliegue desde el día uno. Configurarlo al principio cuesta 10 minutos; hacerlo al final, con la fecha de entrega encima, cuesta bastante más.

El código completo está en el repositorio, con licencia MIT — clónalo, despliégalo, rómpelo.

Top comments (0)