41 Commits

Author SHA1 Message Date
root 70129b1e9f docs: add CI/CD optimization proposal (draft, post-first-deploy to refine)
CI/CD Pipeline / Build & Deploy (pull_request) Successful in 1m7s
Documents the current pipeline anatomy, the metrics to capture on the
first real run, hypothesised bottlenecks, and ranked optimisation
proposals. Placeholders for real numbers; to be filled in after the
first deploy lands.

Companion to the Portainer redeploy step in .gitea/workflows/ci-cd.yaml
on the same branch.
2026-07-10 11:11:34 +00:00
root ab6b4677b4 ci: gate redeploy step on workflow_dispatch.deploy=true
- Build pushes both :latest and :0.0.1 tags so the redeploy step uses the
  pinned version regardless of how the runner happened to be tagged before.
- Add a 4th step 'Redeploy stack on Portainer' that runs ONLY when the
  workflow is manually dispatched with deploy=true.
- Step does idempotent DELETE+CREATE on the existing stack via Portainer REST
  (PUT-edit would require more plumbing for a single-service stack).
- Smoke-tests http://<host>:3001/ after the stack recreates; logs the code
  but does not hard-fail the job (network-proxy reachable via NPM upstream).

Required new secrets (in addition to existing DOCKER_USERNAME/DOCKER_PASSWORD):
- PORTAINER_URL         e.g. http://192.168.1.30:9000
- PORTAINER_TOKEN       API access token from a Portainer user
- PORTAINER_ENDPOINT_ID numeric endpoint ID (usually 1)
- PORTAINER_STACK_ID    numeric stack ID to delete before recreating
2026-07-10 10:54:17 +00:00
root e346e6500d Merge PR #3: docs: add last site update marker
CI/CD Pipeline / Build & Deploy (push) Successful in 25s
2026-07-09 17:38:22 +02:00
root 6f3c58bfcd docs: add last site update marker on about page
CI/CD Pipeline / Build & Deploy (pull_request) Successful in 29s
2026-07-09 15:38:25 +00:00
root 83db698f8d Merge pull request 'fix(ci): add concurrency guard + PR trigger + workflow_dispatch' (#2) from fix/ci-concurrency into main
CI/CD Pipeline / Build & Deploy (push) Successful in 17s
2026-07-09 17:23:50 +02:00
root f152379882 fix(ci): add concurrency guard + PR trigger + workflow_dispatch
CI/CD Pipeline / Build & Deploy (pull_request) Successful in 22s
Apply the same concurrency pattern used in the python-project-template:
- cancel-in-progress: false prevents the shared act_runner from
  cancelling in-progress steps when a new push lands
- pull_request trigger ensures PRs are validated before merge
- workflow_dispatch allows manual re-runs
2026-07-09 15:23:40 +00:00
root d3aed167fa Merge pull request 'docs: personalized README' (#1) from docs/readme into main
CI/CD Pipeline / Build & Deploy (push) Successful in 23s
2026-07-09 17:23:04 +02:00
root 26f26c89f6 docs: personalized README 2026-07-09 15:22:48 +00:00
wh-leader 4ab6633cc2 feat: add Modbus TCP vs EEBUS SPINE technical comparison blog post
CI/CD Pipeline / Build & Deploy (push) Successful in 23s
2026-05-11 14:48:46 +02:00
wh-leader 904684e6b8 fix: remove TimeNet CLI from About page (internal tool)
CI/CD Pipeline / Build & Deploy (push) Successful in 24s
2026-05-11 14:04:29 +02:00
wh-leader 54cad6684c fix: add blog pages and fix project schema, remove internal TimeNet CLI
CI/CD Pipeline / Build & Deploy (push) Successful in 23s
2026-05-11 14:02:08 +02:00
wh-leader 3f7e54c72e feat: add Python Project Template and TimeNet CLI projects
CI/CD Pipeline / Build & Deploy (push) Failing after 21s
2026-05-11 13:58:40 +02:00
wh-leader 0b7021f827 feat: translate About page to English
CI/CD Pipeline / Build & Deploy (push) Successful in 21s
2026-05-11 13:57:05 +02:00
wh-leader 21a63b6947 fix(ci): remove Portainer deployment step (runner network isolated)
CI/CD Pipeline / Build & Deploy (push) Successful in 23s
2026-05-11 13:49:10 +02:00
wh-leader c514b3bce1 fix: remove empty github field causing Astro build error
CI/CD Pipeline / Build & Deploy (push) Failing after 2m32s
2026-05-11 13:42:15 +02:00
wh-leader 58f0a00d4c fix(ci): use plain docker build instead of buildx (runner /dev/null issue)
CI/CD Pipeline / Build & Deploy (push) Failing after 17s
2026-05-11 13:33:30 +02:00
wh-leader 83845f4894 fix(ci): use exact WarrantyHub working pattern with Docker actions
CI/CD Pipeline / Build & Deploy (push) Failing after 36s
2026-05-11 13:14:46 +02:00
wh-leader 7e90812373 fix(ci): use GITEATOKEN secret name
CI/CD Pipeline / Build & Deploy (push) Failing after 15s
2026-05-11 13:13:41 +02:00
wh-leader 1037f6a4ed fix(ci): add auth token to git clone + consolidate steps
CI/CD Pipeline / Build & Deploy (push) Failing after 16s
2026-05-11 13:13:12 +02:00
wh-leader 8176b0b09b fix(ci): use plain docker + git instead of GitHub actions
CI/CD Pipeline / Build & Deploy (push) Failing after 28s
2026-05-11 13:09:48 +02:00
wh-leader 5937425872 trigger: test CI with Portainer secrets
CI/CD Pipeline / Build & Deploy (push) Failing after 43s
2026-05-11 13:08:12 +02:00
wh-leader 42873ad9cc fix(ci): force image pull via Portainer API (stop + pull + start)
CI/CD Pipeline / Build & Deploy (push) Failing after 41s
2026-05-11 13:06:15 +02:00
wh-leader 35f3ba8767 fix: use exact working CI pattern from WarrantyHub
CI/CD Pipeline / Build & Deploy (push) Failing after 42s
2026-05-11 13:03:04 +02:00
wh-leader ce8415c26f security: remove IP addresses and internal hostnames from About page
CI/CD Pipeline / Build & Deploy (push) Failing after 43s
2026-05-11 12:57:05 +02:00
wh-leader 8cd0b2fa24 feat: complete content overhaul - projects, infrastructure, personal brand (no empire talk)
CI/CD Pipeline / Build & Deploy (push) Failing after 42s
2026-05-11 12:55:24 +02:00
wh-leader 8a30951a64 refactor: homepage with personal brand focus (no empire talk) + profile photo
CI/CD Pipeline / Build & Deploy (push) Failing after 47s
2026-05-11 12:51:14 +02:00
wh-leader f93ae22b77 feat: add first blog post - De Orfebre a Builder
CI/CD Pipeline / Build & Deploy (push) Failing after 47s
2026-05-11 12:39:48 +02:00
wh-leader 65f9defcdd remove: delete blog post with too much strategic detail
CI/CD Pipeline / Build & Deploy (push) Failing after 45s
2026-05-11 12:32:00 +02:00
wh-leader 15c946c0a1 feat: add first blog post - Arrancando el Viaje
CI/CD Pipeline / Build & Deploy (push) Failing after 45s
2026-05-11 12:29:43 +02:00
wh-leader ada08a31be feat: add David profile photo to About page
CI/CD Pipeline / Build & Deploy (push) Failing after 48s
2026-05-11 12:25:30 +02:00
wh-leader d11c9a6a1e feat: add Portainer production compose (stack ID 139)
CI/CD Pipeline / Build & Deploy (push) Failing after 51s
2026-05-11 12:15:30 +02:00
wh-leader 259fef1f95 fix: install all dependencies including devDependencies for build
CI/CD Pipeline / Build & Deploy (push) Failing after 49s
2026-05-11 12:04:09 +02:00
wh-leader 47bf985a79 fix: use official Docker actions like WarrantyHub workflow
CI/CD Pipeline / Build & Deploy (push) Failing after 41s
2026-05-11 11:25:29 +02:00
wh-leader 8d85589f0c fix: use direct Docker build to avoid runner filesystem issues
CI/CD Pipeline / Build & Deploy (push) Failing after 24s
Gitea Actions runner has filesystem mount issue preventing Node.js extraction.
Solution: Build entire image with Docker (includes Node) instead of setup-node action.
Simpler and more reliable - build happens inside container.
2026-05-11 09:32:53 +02:00
wh-leader c31692bae0 fix: remove npm cache from Gitea Actions
CI/CD Pipeline / Build Astro Site (push) Failing after 22s
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
Gitea Actions may not support npm cache like GitHub Actions does.
Simplify to just node version.
2026-05-11 09:29:28 +02:00
wh-leader 695dad2770 ci: add production docker-compose for Portainer stack
CI/CD Pipeline / Build Astro Site (push) Failing after 19s
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
Uses registry image instead of build context.
CI will push to gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
2026-05-11 09:28:41 +02:00
wh-leader 01ce85a140 fix: docker-compose version must be quoted string for Portainer
CI/CD Pipeline / Build Astro Site (push) Failing after 21s
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
2026-05-11 09:27:04 +02:00
wh-leader e9be93f24d docs: add CI/CD setup instructions
CI/CD Pipeline / Build Astro Site (push) Failing after 1m8s
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
2026-05-11 09:24:08 +02:00
wh-leader cc7043148a ci: add Gitea Actions CI/CD with Portainer deployment
CI/CD Pipeline / Build Astro Site (push) Failing after 1m8s
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
Add complete CI/CD pipeline:
- Gitea Actions workflow (build + deploy)
- Multi-stage Dockerfile (Node build + nginx serve)
- nginx config with SPA routing and cache headers
- docker-compose.yml for local testing
- .dockerignore to optimize build

Pipeline flow:
1. Build job: npm ci + npm build + upload artifact
2. Deploy job (main only): Docker build + push to registry + Portainer webhook

Requires Gitea secrets:
- DOCKER_USERNAME
- DOCKER_PASSWORD
- PORTAINER_WEBHOOK_URL
2026-05-11 09:23:29 +02:00
wh-leader 600e9ac3b4 feat: add homepage, content collections, and utils
- Homepage with hero, featured projects, latest posts sections
- Content collections config (blog + projects schemas)
- Date formatting and reading time utilities
- Sample blog post and project for validation
2026-05-11 07:43:04 +02:00
wh-leader 05036766e4 feat: recover portfolio pages from scratch workspaces
- Add base layouts (BaseLayout, BlogLayout, ProjectLayout)
- Add UI components (Header, Footer, Navigation, Card, Tag)
- Add About page with personal story
- Add Projects pages (index, detail)
- Add homepage content
- Add SEO files (robots.txt, webmanifest)

Work was done by agents in isolated workspaces.
Consolidated into main repo for proper git tracking.
2026-05-11 07:41:22 +02:00
38 changed files with 2275 additions and 42 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
.git
.gitignore
.vscode
.idea
*.md
!README.md
.env
.env.*
dist
.astro
+121
View File
@@ -0,0 +1,121 @@
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
deploy:
description: "Recreate the davidaragon-portfolio stack on Portainer (deletes + creates with latest image)"
type: boolean
default: false
required: false
# Serialise runs on the shared act_runner: prevents two simultaneous runs from
# cancelling each other's in-progress steps (observed in older portfolio runs).
# See python-project-template-internal/.gitea/workflows/ci.yaml for the same
# pattern with full context (runs 794 and 799).
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build & Deploy
runs-on: ubuntu-latest
env:
# IMAGE_NAME used by the redeploy step + the deploy compose file.
IMAGE_NAME: gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
steps:
- uses: actions/checkout@v4
- name: Build and push Docker image
run: |
# The build always tags and pushes both :latest (for ad-hoc inspection)
# and :0.0.1 (pinned version that the stack redeploy step uses).
# The runner's `docker buildx` is set up by the QNAP self-hosted runner;
# no QEMU emulation required for the linux/amd64 build target here.
docker login gitlab.impresion3d.pro -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKER_PASSWORD }}"
docker build \
-t gitlab.impresion3d.pro/root/davidaragon-portfolio:latest \
-t gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1 \
.
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
# ----------------------------------------------------------------------
# Optional redeploy step (gated by workflow_dispatch.deploy=true).
#
# Approach: delete the existing stack on Portainer (idempotent), then
# create a new one from the docker-compose.prod.yml at the repo root.
#
# We DELETE + CREATE rather than PUT-edit because:
# * Put-edit requires the same stack ID + PRUNE; the simpler recreate
# works for our size (1 service, no inter-service references).
# * Earlier the stack was "unhealthy"; this gives a clean slate.
#
# Required secrets:
# PORTAINER_URL e.g. http://192.168.1.30:9000
# PORTAINER_TOKEN Access token from a Portainer user (scope: admin)
# PORTAINER_ENDPOINT_ID Numeric endpoint ID (usually 1)
# PORTAINER_STACK_ID Numeric stack ID to delete before recreating
# ----------------------------------------------------------------------
- name: Redeploy stack on Portainer
if: github.event_name == 'workflow_dispatch' && inputs.deploy == true
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
PORTAINER_TOKEN: ${{ secrets.PORTAINER_TOKEN }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }}
run: |
set -euo pipefail
echo "--- Step 1: pre-flight (delete existing stack if present) ---"
DELETE_HTTP_CODE=$(curl -sS -o /tmp/portainer-delete.json -w '%{http_code}' \
-X DELETE \
-H "X-API-Key: ${PORTAINER_TOKEN}" \
"${PORTAINER_URL}/api/stacks/${PORTAINER_STACK_ID}?endpointId=${PORTAINER_ENDPOINT_ID}")
echo "DELETE HTTP ${DELETE_HTTP_CODE}"
if [ "${DELETE_HTTP_CODE}" != "204" ] && [ "${DELETE_HTTP_CODE}" != "404" ]; then
echo "ERROR: Portainer rejected DELETE on stack ${PORTAINER_STACK_ID}:" >&2
cat /tmp/portainer-delete.json >&2
exit 1
fi
echo "--- Step 2: read docker-compose.prod.yml ---"
if [ ! -f docker-compose.prod.yml ]; then
echo "ERROR: docker-compose.prod.yml is missing from the repo root" >&2
exit 1
fi
# Inline the compose file. Portainer expects `composeFileContent` as raw text.
COMPOSE_BODY=$(jq -Rs --arg compose "$(cat docker-compose.prod.yml)" \
'{composeFileContent: $compose, env: []}' < /dev/null)
echo "--- Step 3: create fresh stack from docker-compose.prod.yml ---"
CREATE_HTTP_CODE=$(curl -sS -o /tmp/portainer-create.json -w '%{http_code}' \
-X POST \
-H "X-API-Key: ${PORTAINER_TOKEN}" \
-H "Content-Type: application/json" \
--data "${COMPOSE_BODY}" \
"${PORTAINER_URL}/api/stacks?endpointId=${PORTAINER_ENDPOINT_ID}&type=2&method=string&name=davidaragon-portfolio")
echo "CREATE HTTP ${CREATE_HTTP_CODE}"
if [ "${CREATE_HTTP_CODE}" != 201 ]; then
echo "ERROR: Portainer rejected stack creation:" >&2
cat /tmp/portainer-create.json >&2
exit 1
fi
echo "--- Step 4: smoke-test the freshly deployed stack ---"
# Give the container a brief window to start before checking.
sleep 8
HEALTH=$(curl -sS -o /dev/null -w '%{http_code}' \
--max-time 5 \
"http://${PORTAINER_URL#http://}:3001/" 2>/dev/null || true)
echo "Health check on http://<host>:3001/ returned: ${HEALTH:-<timeout/unreachable>}"
# We log but don't fail the job if 3001 isn't reachable — the upstream
# proxy (Nginx Proxy Manager → davidaragon.impresion3d.pro) is a better
# place to wire a hard-fail check in a future iteration.
echo "--- Stack recreated successfully. ---"
+145
View File
@@ -0,0 +1,145 @@
# CI/CD Setup Instructions
## ✅ What's Done
1. **Gitea Actions workflow** created (`.gitea/workflows/ci-cd.yaml`)
- Build job: Verify Astro build works
- Deploy job: Build Docker image + push to registry + trigger Portainer
2. **Docker files** created:
- `Dockerfile` - Multi-stage build (Node + nginx)
- `docker-compose.yml` - Local testing
- `nginx.conf` - SPA routing + cache headers
- `.dockerignore` - Optimize build
3. **Pushed to git** - CI will run on next push to main branch
---
## 🔧 Manual Steps Required
### 1. Configure Gitea Secrets
Go to: https://gitlab.impresion3d.pro/root/davidaragon-portfolio/settings/secrets
Add these secrets:
#### `DOCKER_USERNAME`
```
root
```
#### `DOCKER_PASSWORD`
```
<your Gitea personal access token with registry permissions>
```
#### `PORTAINER_WEBHOOK_URL`
```
<webhook URL from Portainer stack - see step 2>
```
### 2. Create Portainer Stack
1. Go to Portainer: http://192.168.1.30:9000
2. Navigate to: **Stacks****Add stack**
3. Name: `davidaragon-portfolio`
4. Build method: **Git Repository** or **Web editor**
#### Stack compose file:
```yaml
version: '3.8'
services:
portfolio:
image: gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
container_name: davidaragon-portfolio
ports:
- "8081:80"
restart: unless-stopped
environment:
- NODE_ENV=production
networks:
- web
networks:
web:
external: true
```
5. Deploy the stack
6. Go to stack **Webhooks** tab
7. Create webhook for service `portfolio`
8. Copy webhook URL (format: `http://192.168.1.30:9000/api/stacks/webhooks/...`)
9. Add this URL as `PORTAINER_WEBHOOK_URL` secret in Gitea (step 1)
### 3. Optional: Configure Gitea Runner
If no runners registered yet:
```bash
# On server 192.168.1.30 (port 2222)
docker run -d \
--name gitea-runner-portfolio \
--restart unless-stopped \
-e GITEA_INSTANCE_URL=https://gitlab.impresion3d.pro \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
-e GITEA_RUNNER_NAME=ubuntu-latest \
-v /var/run/docker.sock:/var/run/docker.sock \
gitea/act_runner:latest
```
Get registration token from: https://gitlab.impresion3d.pro/admin/actions/runners
---
## 🚀 How It Works
### On Every Push to Main:
1. **Build job** runs:
- Install Node.js 20
- Install dependencies (`npm ci`)
- Build Astro site (`npm run build`)
- Upload `dist/` artifact
2. **Deploy job** runs (only if build succeeds):
- Download `dist/` artifact
- Build Docker image (multi-stage)
- Push to Gitea registry: `gitlab.impresion3d.pro/root/davidaragon-portfolio:latest`
- Trigger Portainer webhook → Portainer pulls new image and restarts container
### Result:
- Site auto-deployed to http://192.168.1.30:8081
- No manual intervention needed after initial setup
---
## 🧪 Test Locally
```bash
cd ~/code_ubuntu/davidaragon-portfolio
# Build and run with Docker Compose
docker-compose up --build
# Visit: http://localhost:8080
```
---
## 📝 Next Steps
1. Configure secrets in Gitea (step 1)
2. Create Portainer stack (step 2)
3. Add webhook URL to Gitea secrets
4. Push any commit to main → CI/CD runs automatically!
---
## 🔍 Monitor CI/CD
- **Actions**: https://gitlab.impresion3d.pro/root/davidaragon-portfolio/actions
- **Registry**: https://gitlab.impresion3d.pro/root/-/packages/container/davidaragon-portfolio
- **Portainer**: http://192.168.1.30:9000/#!/stacks
+36
View File
@@ -0,0 +1,36 @@
# Multi-stage build for Astro static site
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install ALL dependencies (build needs devDependencies)
RUN npm ci
# Copy source
COPY . .
# Build static site
RUN npm run build
# Stage 2: Production - nginx
FROM nginx:alpine
# Copy built site to nginx html directory
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx config (if needed for SPA routing)
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
+156 -28
View File
@@ -1,43 +1,171 @@
# Astro Starter Kit: Minimal # David Aragón — Portfolio
```sh Portfolio personal y blog técnico de David Aragón. Sitio estático construido con Astro 5, donde comparto proyectos en los que estoy trabajando (build in public), posts sobre IoT, desarrollo y decisiones técnicas, y una página de about con mi trayectoria.
npm create astro@latest -- --template minimal
- **URL pública:** https://davidaragon.impresion3d.pro
- **Autor:** David Aragón — de orfebre a fotógrafo a developer
- **Repositorio:** https://gitlab.impresion3d.pro/root/davidaragon-portfolio
## Características
- **Home** con hero, últimos 3 posts del blog, proyectos destacados y CTA de newsletter
- **Blog** (`/blog`) con posts categorizados en `technical`, `business` y `personal`, con RSS en `/rss.xml`
- **Projects** (`/projects`) con proyectos en estados `active`, `development` o `completed`
- **About** (`/about`) con la trayectoria profesional completa
- **Sitemap** autogenerado en `/sitemap-index.xml` por `@astrojs/sitemap`
- **Newsletter** vía formulario en la home (acción `/api/subscribe`)
- **Type-safe content** con Zod schemas en `src/content/config.ts`
- **Búsqueda y SEO**: meta tags, Open Graph y RSS listos para compartir
## Stack técnico
- **Astro 5** — framework de sitios estáticos con islands
- **TypeScript** — tipado estricto en todo el código
- **Tailwind CSS 3** — utility-first CSS con `@tailwindcss/typography` para prose
- **Zod** — validación de frontmatter en content collections
- **RSS** vía `@astrojs/rss`
- **Sitemap** vía `@astrojs/sitemap`
- **Nginx** como servidor estático en el contenedor de producción
- **Docker** multi-stage (Node para build, nginx para servir)
- **CI/CD** con Gitea Actions en `.gitea/workflows/ci-cd.yaml`
- **Deploy** automático a Portainer vía webhook
## Quick start (local)
Requisitos: Node.js >= 18.14.1 (recomendado 20).
```bash
# 1. Instalar dependencias
npm install
# 2. Arrancar dev server con HMR
npm run dev
# -> http://localhost:4321
# 3. Build de producción (genera ./dist/)
npm run build
# 4. Preview local del build
npm run preview
``` ```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! ## Despliegue (producción)
## 🚀 Project Structure El sitio se construye como imagen Docker y se publica en el registry de Gitea. Portainer hace pull y reinicia el contenedor cuando el CI dispara el webhook.
Inside of your Astro project, you'll see the following folders and files: ```bash
# Construir y servir localmente con Docker Compose
docker compose up --build
# -> http://localhost:8080
```text # Producción: pull de la imagen publicada por CI
/ docker compose -f docker-compose.prod.yml up -d
├── public/ # -> http://localhost:3001
├── src/
│ └── pages/
│ └── index.astro
└── package.json
``` ```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. La imagen final (`nginx:alpine`) solo contiene los estáticos generados en `./dist/`. Ver `Dockerfile` para detalles del multi-stage.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. ## Cómo añadir contenido
Any static assets, like images, can be placed in the `public/` directory. ### Nuevo post de blog
## 🧞 Commands Crea un archivo Markdown en `src/content/blog/` con nombre `YYYY-MM-DD-slug.md`. El frontmatter debe cumplir el schema de `src/content/config.ts`:
All commands are run from the root of the project, from a terminal: ```markdown
---
title: "Título del post"
description: "Resumen corto para SEO y previews"
publishDate: 2026-07-09
author: "David Aragón" # opcional, default: David Aragón
tags: ["tag1", "tag2"]
category: "technical" # technical | business | personal
featured: false # true para destacar en home
draft: false # true para ocultar del sitio público
image: "/ruta/opcional.jpg" # opcional
---
| Command | Action | # Contenido del post en Markdown
| :------------------------ | :----------------------------------------------- | ```
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more? Aparecerá automáticamente en `/blog/<slug>` y, si no es draft, en la home.
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). ### Nuevo proyecto
Crea un archivo Markdown en `src/content/projects/` con frontmatter:
```markdown
---
title: "Nombre del proyecto"
description: "Descripción de una línea"
url: "https://proyecto.example.com"
github: "https://github.com/user/repo" # opcional
status: "development" # active | development | completed
tags: ["React", "FastAPI"]
startDate: 2026-07-01
featured: true # true para mostrar en home
image: "/ruta/opcional.jpg" # opcional
---
# Descripción completa del proyecto en Markdown
```
Aparecerá en `/projects/<slug>` y, si `featured: true`, también en la home.
## Estructura del proyecto
```
.
├── astro.config.mjs # Configuración Astro (site, integrations, markdown)
├── tailwind.config.cjs # Tailwind theme y content paths
├── tsconfig.json # TS estricto
├── package.json # Deps y scripts
├── Dockerfile # Multi-stage build (node -> nginx)
├── docker-compose.yml # Local dev/test
├── docker-compose.prod.yml # Producción (pull de registry)
├── nginx.conf # Configuración nginx
├── .gitea/workflows/ # Pipelines CI/CD
│ └── ci-cd.yaml
├── public/ # Assets estáticos servidos tal cual (favicon, david-aragon.jpg, etc.)
└── src/
├── components/
│ ├── layout/ # Header, Footer
│ └── ui/ # Card, Tag
├── content/
│ ├── config.ts # Schemas Zod para blog y projects
│ ├── blog/ # Posts en Markdown
│ └── projects/ # Proyectos en Markdown
├── layouts/ # BaseLayout, BlogLayout, ProjectLayout
├── pages/
│ ├── index.astro # Home
│ ├── about.astro # About
│ ├── blog/ # Index + [slug].astro
│ ├── projects/ # Index + [slug].astro
│ └── rss.xml.ts # Feed RSS (si está generado)
├── styles/ # CSS global
└── utils/ # dateFormat.ts, readingTime.ts
```
## CI/CD
Pipeline definido en `.gitea/workflows/ci-cd.yaml`. Trigger: push a `main`.
1. **Build**: checkout, login en el registry de Gitea, build de la imagen Docker multi-stage
2. **Push**: publica `gitlab.impresion3d.pro/root/davidaragon-portfolio:latest`
3. **Deploy**: el webhook configurado en Portainer hace pull de la nueva imagen y reinicia el contenedor
Secrets necesarios en el repo (Settings → Secrets):
- `DOCKER_USERNAME` — usuario del registry (normalmente `root`)
- `DOCKER_PASSWORD` — token personal con permisos de registry
Ver `CI-CD-SETUP.md` para instrucciones detalladas de Portainer y registro de runners.
## Licencia
Código del sitio: ver `LICENSE` si existe en el repo. El contenido (posts y proyectos) es © David Aragón.
## Créditos
- Construido con [Astro](https://astro.build)
- Estilado con [Tailwind CSS](https://tailwindcss.com)
- Hospedado en infraestructura propia (Gitea + Portainer)
+10
View File
@@ -0,0 +1,10 @@
version: "3.8"
services:
portfolio:
image: gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
container_name: davidaragon-portfolio
restart: unless-stopped
ports:
- "3001:80"
+17
View File
@@ -0,0 +1,17 @@
version: "3.8"
services:
portfolio:
build: .
container_name: davidaragon-portfolio
ports:
- "8080:80"
restart: unless-stopped
environment:
- NODE_ENV=production
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
+326
View File
@@ -0,0 +1,326 @@
# CI/CD optimization proposal — davidaragon-portfolio
**Status**: Draft v0.1 — initial scaffold, real measurements pending.
**Last updated**: 2026-07-10
**Owner**: David Aragón + Hermes
**Related**: [`docs/architecture.md`](../architecture.md) · [`docs/ci-cd-setup.md`](../ci-cd-setup.md)
---
## 0. Why this document exists
The CI for `davidaragon-portfolio` is the second pipeline brought into the
Gitea Actions + Portainer setup on the NAS (the first being
`python-project-template-internal`). Both share the same QNAP-hosted
`act_runner` self-hosted runner and the same Portainer instance — that means
**an optimization here is partly informed by what we already learned on the
Python template**, and partly needs its own measurements because the build is
different (Node 20 + Astro static vs Python 3.10 + uv).
The first real deploy of the portfolio stack is the gating event: until that
runs and we can read the timing breakdown, this document is **structural** +
**conjectural** for any section that needs real numbers. Once we have one
truthful run, the open `[METRIC]` placeholders get filled in.
---
## 1. Anatomy of the current pipeline
The workflow lives at `.gitea/workflows/ci-cd.yaml` and currently has:
```
push / pull_request / workflow_dispatch(deploy:boolean)
|
+--> job: build (runs-on: ubuntu-latest)
|
+--> step 1: actions/checkout@v4
+--> step 2: docker login + build + push
| (tags: :latest and :0.0.1)
+--> step 3 (conditional): Redeploy stack on Portainer
|
+--> a) DELETE existing stack on Portainer API
+--> b) POST a fresh stack from docker-compose.prod.yml
+--> c) sleep 8s, then smoke-test http://host:3001/
```
### The runner
- `runs-on: ubuntu-latest` — no `self-hosted` label required.
- The QNAP-resident `gitea/act_runner:latest` registers with these labels
(per the existing CI config that runs successfully today):
- `ubuntu-latest`
- `linux`
- `x64`
- probably `act-runner` (informally used internally)
- Single shared runner. Iteration `iter 5` of the Python template proved
that without `concurrency:` group with `cancel-in-progress: false`, two
parallel runs cancel each other's mid-flight steps. **This workflow
inherited that fix from PR #2** (see `f152379 fix(ci): add concurrency
guard + PR trigger + workflow_dispatch`). No regression expected on this
repo.
### What runs on each step
| Step | Container / binary used | On the runner | Notes |
|------|--------------------------|---------------|-------|
| 1 (checkout) | `actions/checkout@v4` (Node) | Yes | Adds ~1s |
| 2 (build) | local `docker buildx` | Yes | Pulls `node:20-alpine` + `nginx:alpine`. **Network bound.** |
| 3 (deploy) | local `curl` + `jq` | Yes | Calls Portainer API at `http://${PORTAINER_URL}/api/stacks/...`. **Internal network bound.** |
The runner must therefore have:
- Outbound HTTPS to `registry.npmjs.org` (during build, npm ci)
- Outbound HTTPS to `gitlab.impresion3d.pro:443` (during push)
- Network reachability to the Portainer URL (`PORTAINER_URL` secret). When
that URL is `http://192.168.1.30:9000`, the runner needs to be on the same
LAN as the NAS. The QNAP self-hosted runner satisfies this only if the
runner container has access to the LAN (either via `--network host` on
the runner or via a docker network with a route to `192.168.1.0/24`).
The third point is the most likely silent failure: a `--network=some-bridge`
runner cannot resolve `192.168.1.30` by default and the redeploy step will
then fail with `connection refused` or `No route to host`. **Before the first
real deploy**, the runner's network scope should be verified — see the
[verification checklist](#7-verification-checklist-before-first-real-deploy).
---
## 2. Critical metrics to capture on the first deploy
The first real run will fill these in. Once captured, copy them into this
section and update the subsequent analysis.
| Metric | Source | Target | Actual (first run) |
|---|---|---|---|
| `t_checkout` | logs of step 1 | < 5 s | `[METRIC]` |
| `t_docker_pull_alpine_node` | logs of step 2 (first lines) | < 30 s | `[METRIC]` |
| `t_npm_ci` | step 2 lines mentioning `npm ci` | < 60 s | `[METRIC]` |
| `t_astro_build` | step 2 lines mentioning `astro build` | < 30 s | `[METRIC]` |
| `t_docker_build_total` | step 2 end timestamp - step 2 start | < 180 s | `[METRIC]` |
| `t_docker_push_latest` | step 2 lines mentioning `latest` push | < 20 s | `[METRIC]` |
| `t_docker_push_pinned` | step 2 lines mentioning `:0.0.1` push | < 20 s | `[METRIC]` |
| `t_step_3_delete` | step 3 DELETE line | < 5 s | `[METRIC]` |
| `t_step_3_create` | step 3 POST line | < 5 s | `[METRIC]` |
| `t_step_3_smoke_total` | step 3 sleep + curl | ~8 s + connect time | `[METRIC]` |
| **Total wall-clock (with deploy)** | run-level duration | < 5 min | `[METRIC]` |
| **Total wall-clock (without deploy)** | run-level duration | < 3 min | `[METRIC]` |
| Runner queue waiting time | run started - run created | < 60 s | `[METRIC]` |
To capture these, the easiest path is to download the job logs via the
Gitea API once the run is complete:
```bash
# Get run_id from the Gitea UI (or /api/v1/repos/.../actions/runs)
curl -s -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/repos/root/davidaragon-portfolio/actions/runs/<RUN_ID>/jobs" \
| jq '.[].id'
# Then fetch logs per job
curl -s -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/repos/root/davidaragon-portfolio/actions/jobs/<JOB_ID>/logs"
```
The `gitea-actions-ci-debugging` skill in the agent's repo has more on this
pattern (see `references/api-endpoints.md`).
---
## 3. Hypothesised bottlenecks (to be verified against §2)
The following are **guesses** to be confirmed or refuted with the first run.
They are listed in order of expected impact.
### H1. Docker layer cache cold on every run
- **Why**: The runner does not persist `docker buildx` cache between runs
unless a layer cache is configured.
- **Symptom**: `t_docker_pull_alpine_node` likely to be ~30s on cold cache.
- **Possible fix (sized for the runner's disk)**:
- Mount a host directory to `~/.cache/buildx` and export `BUILDX_CACHE` env.
- Or use `docker/build-push-action@v6` with `cache-from: type=registry`.
- **Effort / risk**: low. Touches only step 2.
### H2. Sequential `:latest` then `:0.0.1` pushes
- **Why**: Image bytes are uploaded twice (once per tag), doubling push time.
- **Fix**: tag both and push only `:0.0.1`, then `:latest` is left for ad-hoc
inspection. Or use `--all-tags` (`docker push --all-tags`) which sends
each unique manifest only once if the registry supports it
(Gitea container registry supports it since 1.20).
- **Saving**: ~50% of push time (typically < 10s on LAN).
### H3. npm ci always re-downloads when `package-lock.json` changes
- **Why**: Without an `npm` cache, every push with a new lock entry is a fresh
download from `registry.npmjs.org`.
- **Symptom**: `t_npm_ci` proportional to dep delta. With the current lock
file (Astro 5 + Tailwind + Zod), cold downloads are ~30s for the small
tree, but as more plugins are added this grows.
- **Fix**: use `actions/setup-node@v4` with `cache: 'npm'` (it caches based on
`package-lock.json` hash automatically). Or use `cache@v4` directly with
path `node_modules` and key derived from the lock hash.
- **Saving**: 30s+ per run on a miss, ~5s on a hit.
### H4. Image pull-from-registry at container runtime
- **Symptom in step 3**: the first health check at `:3001/` may be slow on
cold cache because the new container has to pull `...:0.0.1` from the
registry. The compose file does not pin `pull_policy: always`, so the
runner may use an older layer cache on the runner host.
- **Fix**: add `pull_policy: always` to `docker-compose.prod.yml` so the new
image is fetched every time.
### H5. Concurrency guard might be too coarse
- **Why**: The current group is `ci-${{ github.ref }}` — per ref. This
serialises pushes to `main` AND pushes to PR branches on top of each other.
With a single runner, this is correct. If a second runner comes online
later, the guard may need to be re-evaluated.
- **No action for now**. Re-evaluate when a second runner is registered.
---
## 4. Optimisation proposals (ranked by ROI)
These are **proposals** to apply after the first run, ranked by expected
return on effort.
### Proposal A — Add `actions/setup-node@v4` with npm cache (effort: 5 min, expected saving: 30s/run on miss, 5s on hit)
Add as a new step between checkout and build:
```yaml
- uses: actions/setup-node@v4
with:
node-version: '20' # matches Dockerfile
cache: 'npm' # caches ~/.npm based on package-lock.json hash
```
This costs nothing in code maintenance and helps every PR build.
### Proposal B — Switch push to `--all-tags` (effort: 2 min, saving: ~10s/run)
Replace:
```bash
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
```
with:
```bash
docker push --all-tags gitlab.impresion3d.pro/root/davidaragon-portfolio
```
Both tags get pushed in one HTTP exchange; the registry deduplicates identical blobs.
### Proposal C — Add `pull_policy: always` to compose (effort: 1 min, saving: variable)
Add the line to `docker-compose.prod.yml`:
```yaml
services:
portfolio:
image: gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
pull_policy: always # <-- new
restart: unless-stopped
ports:
- "3001:80"
```
Without this, the runner host may serve a cached older layer to the new
container even though the tag on the registry has changed.
### Proposal D — Drop the `:latest` tag entirely (effort: 5 min, saving: 10s/run, cost: 1 fewer tag for ad-hoc inspection)
Pure-only-`:0.0.1` means every pull is explicit (CI passes the pinned tag).
Pros: zero ambiguity in `docker images` listings. Cons: less convenient for
`docker exec -it foo /bin/sh` quick explorations.
Not recommended unless the user explicitly asks — the `:latest` is useful as
a "what's currently live" pointer.
### Proposal E — Squash the image with `docker-slim` or `dive` optimisation (effort: 1 hour+, saving: 20-40 MB image size)
Probably **not worth it** for a portfolio site. Skip.
### Proposal F — Parallel runs (long-term)
Currently the pipeline has a single `build` job. If lint, type-check, and
build grow apart in cost, splitting them into parallel jobs would help. But
the Astro site has no type-checking step (vanilla JS), and a project this
small does not benefit from parallelisation. Leave as-is.
---
## 5. Open questions for the user
- Should the build always push both `:latest` and `:0.0.1`, or only the
pinned version? (Affects Proposal D)
- Is there any reason to keep the smoke-test in step 3 (`http://host:3001/`)?
The actual public URL is via NPM (`https://davidaragon.impresion3d.pro`).
Smoke-testing the public URL would be more accurate but requires the
runner to have outbound internet AND DNS for the FQDN.
---
## 6. Risks of the current design
- **Single runner bottleneck**. The QNAP has exactly one `act_runner`
container; pushes to two repos (template + portfolio) serialise. If both
repos ever have long-running builds, developer iteration slows. **No fix
for now** (registering a second runner is on the long-run roadmap).
- **`pull_policy` not set**. The compose does not force a pull-on-create.
See Proposal C above.
- **No `if: failure()` retry or notify step**. If the Portainer redeploy
fails, the run ends with `failure` but no notification pings anyone. The
user has to look at the Actions tab manually. **Add a Slack/email
notification step in a future iteration** (low priority while there is
one user).
- **The `IMAGE_NAME` env var in the workflow is hardcoded**. When the
version bumps from `0.0.1` to `0.0.2`, a separate edit is needed in
`ci-cd.yaml`. Could be moved to a `VERSION` repo variable later.
---
## 7. Verification checklist before first real deploy
Run through this list before firing `workflow_dispatch(deploy=true)` to
reduce surprise:
- [ ] `act_runner` is `Idle` (or `Online`) in `https://gitlab.impresion3d.pro/-/admin/actions/runners`. Not in `Restarting`.
- [ ] `act_runner` has the labels `ubuntu-latest`, `linux`, `x64` at minimum.
- [ ] `act_runner`'s docker network can resolve `192.168.1.30` (verify with a debug step that runs `getent hosts gitlab.impresion3d.pro`).
- [ ] Portainer URL responds from the runner (verify with a debug step: `curl -I ${PORTAINER_URL}/api/status`).
- [ ] All 4 secrets are visible in `https://gitlab.impresion3d.pro/root/davidaragon-portfolio/settings/secrets/actions` with values `(hidden)`.
- [ ] `DOCKER_USERNAME` and `DOCKER_PASSWORD` work (verify with `docker login ... -u $U -p $P && docker push <empty-image>` once).
- [ ] The `act_runner` host has at least 2-3 GB free disk (for buildx cache + pulled base images).
The first three items live at the NAS / runner level — see the
`qnap-nas` skill for the diagnostics. Items 4-7 are repo-level.
---
## 8. Proposed workflow to apply A, B, C
After the first deploy succeeds and §2 is filled in, apply A, B, C in a
single PR. The PR title can be `ci: apply optimisations A B C from
docs/ci-optimization-proposal.md`. Estimated effort: 10 minutes. Estimated
saving: ~30-50 s per run.
If §2 reveals a metric that disagrees with §3 (e.g. push is actually fast
and `astro build` is the slow part), the proposal list should be re-ranked
**before applying**, not after.
---
## Appendix A — Convention: when to write a separate skill for this
If the same discovery shows up 3 times across this repo and the Python
template, it graduates from "proposal" to "skill". Candidates so far:
- **Image tag strategy for self-hosted GitLab Container Registry** — both
repos push to the same registry. Worth a skill once the second project
ships the same pattern.
- **Concise Portainer DELETE+CREATE pattern** — the bash snippet in step 3
is reusable. Worth a skill the second time it's copy-pasted.
+51
View File
@@ -0,0 +1,51 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Serve static files with cache headers
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+1
View File
@@ -0,0 +1 @@
Placeholder for favicon - use favicon.io or other generator to create a comprehensive favicon set.
+1
View File
@@ -0,0 +1 @@
Placeholder for avatar - replace with real photo
+1
View File
@@ -0,0 +1 @@
Placeholder for OG image - replace with a 1200x630px image
+8
View File
@@ -0,0 +1,8 @@
User-agent: *
Allow: /
Sitemap: https://davidaragon.impresion3d.pro/sitemap-index.xml
# Disallow admin/private areas (if any)
Disallow: /admin/
Disallow: /api/
+21
View File
@@ -0,0 +1,21 @@
{
"name": "David Aragón - Indie Builder",
"short_name": "DA Portfolio",
"description": "Portfolio personal y blog de indie builder español",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0e27",
"theme_color": "#60a5fa",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+39
View File
@@ -0,0 +1,39 @@
---
const currentYear = new Date().getFullYear();
const socialLinks = [
{ href: 'https://twitter.com/davidaragon', label: 'X', icon: 'X' },
{ href: 'https://linkedin.com/in/davidaragon', label: 'LinkedIn', icon: 'in' },
{ href: 'https://github.com/davidaragon', label: 'GitHub', icon: 'GH' },
];
---
<footer class="mt-auto border-t border-text-tertiary/20 bg-surface">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-sm text-text-secondary">
© {currentYear} David Aragón. Construyendo en público.
</p>
<div class="flex items-center gap-4">
{socialLinks.map(link => (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="text-text-secondary hover:text-primary transition-colors text-sm font-medium"
aria-label={link.label}
>
{link.icon}
</a>
))}
</div>
<a
href="/rss.xml"
class="text-sm text-text-secondary hover:text-primary transition-colors"
>
RSS Feed
</a>
</div>
</div>
</footer>
+14
View File
@@ -0,0 +1,14 @@
---
import Navigation from './Navigation.astro';
---
<header class="sticky top-0 z-50 bg-background/80 backdrop-blur-md border-b border-text-tertiary/20">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<a href="/" class="text-xl font-bold text-primary hover:text-secondary transition-colors">
DA
</a>
<Navigation />
</div>
</div>
</header>
+24
View File
@@ -0,0 +1,24 @@
---
const navItems = [
{ href: '/about', label: 'Sobre mí' },
{ href: '/projects', label: 'Proyectos' },
{ href: '/blog', label: 'Blog' },
];
const currentPath = Astro.url.pathname;
---
<nav class="flex items-center gap-6">
{navItems.map(item => (
<a
href={item.href}
class={`text-sm font-medium transition-colors hover:text-primary ${
currentPath.startsWith(item.href)
? 'text-primary'
: 'text-text-secondary'
}`}
>
{item.label}
</a>
))}
</nav>
+17
View File
@@ -0,0 +1,17 @@
---
export interface Props {
href?: string;
class?: string;
}
const { href, class: className } = Astro.props;
const Component = href ? 'a' : 'div';
---
<Component
href={href}
class={`bg-surface rounded-lg border border-text-tertiary/20 p-6 hover:border-primary/40
transition-colors ${className || ''} ${href ? 'block' : ''}`}
>
<slot />
</Component>
+18
View File
@@ -0,0 +1,18 @@
---
export interface Props {
label: string;
variant?: 'primary' | 'secondary' | 'neutral';
}
const { label, variant = 'neutral' } = Astro.props;
const variants = {
primary: 'bg-primary/10 text-primary border-primary/20',
secondary: 'bg-secondary/10 text-secondary border-secondary/20',
neutral: 'bg-text-tertiary/10 text-text-secondary border-text-tertiary/20',
};
---
<span class={`inline-block px-3 py-1 rounded-full text-sm font-medium border ${variants[variant]}`}>
{label}
</span>
@@ -0,0 +1,60 @@
---
title: "From Goldsmith to Builder: Why I'm Building in Public"
description: "I didn't start as a developer. I started working metal with my hands. Today I build software. This is my story."
publishDate: 2026-05-11
author: "David Aragón"
tags: ["build-in-public", "introduction", "background"]
category: "personal"
featured: true
draft: false
---
# From Goldsmith to Builder: Why I'm Building in Public
I didn't start in tech. I started working metal with my hands.
I was a goldsmith by trade. Then a professional photographer for 13 years. Later a trainer for LinkedIn Learning, teaching about NAS servers and storage. Then a Python developer at NTT Data. And now I work at KEO Connectivity with IoT protocols for energy systems.
Strange paths. But all connected by the same thing: **obsession with understanding how things work, and then making them work better**.
## The Pattern
When I was a photographer, I couldn't afford the equipment I needed. So I improvised. Built supports, modified accessories, made what I had work.
That same impulse led me to build my own 3D printer from scratch. To tinker with Arduino. To learn Python to automate workflows at Qloudea while managing social media and providing technical support.
**The pattern is always the same: identify a problem, obsess over solving it, learn whatever it takes to fix it.**
## Why Now
After years solving problems for others, I decided to solve problems for myself. Build my own products.
I don't have an MBA. I don't have funding. I don't have a team. I just have experience building things, teaching what I learn, and an abnormally high tolerance for not knowing what I'm doing.
And I decided to do it in public because:
1. **Accountability**: It's harder to quit when you share progress publicly
2. **Teaching**: I've always been a trainer. Sharing what I learn is part of who I am
3. **Community**: I want to connect with other Spanish builders on the same journey
## What I'm Working On
Currently focused on **WarrantyHub**, a digital warranty management platform for home users.
Why warranties? Because I saw the problem in real life: chaotic management with papers, Excel, lost emails. And because there's room for a Spanish solution that understands the local market.
I'm using AI aggressively to accelerate development. Not because it's trendy. Because when you're sole developer, sole marketer, sole product manager, sole support, you need every advantage you can get.
## What to Expect
I'll share launches, technical learnings, product decisions, and failures (because they teach more than successes).
Don't expect generic motivational posts or recycled X threads. This is documentation of a real journey.
If you're a builder, this is also for you.
See you in the next post. 🚀
---
_Follow me on [X](https://twitter.com/davidaragon) for more frequent updates._
@@ -0,0 +1,44 @@
---
title: "Modbus TCP vs. EEBUS SPINE: A Technical Comparison for Device Architects"
description: "Every device architect I talk to already knows Modbus. That's precisely where the comparison gets interesting."
publishDate: 2026-05-11
author: "David Aragón"
tags: ["EEBUS", "Modbus", "IoT", "protocols", "energy-management"]
category: "technical"
featured: true
draft: false
---
*Originally published on [LinkedIn](https://www.linkedin.com/pulse/modbus-tcp-vs-eebus-spine-technical-comparison-device-arag%C3%B3n-galiana-qagje/)*
Every device architect I talk to already knows Modbus. That's precisely where the comparison gets interesting.
## Protocol vs. Data Model
Modbus TCP is a communication protocol. SPINE is a data model with communication semantics. Mixing those two categories is where most evaluations go wrong.
In Modbus, **meaning is external**. Register 40031 might hold the current setpoint temperature on one manufacturer's heat pump. On another manufacturer's product, that same value lives in a different register entirely. The register map lives in a vendor PDF, and every integration is a one-off mapping exercise.
In SPINE, **meaning is in the protocol**. A `HeatPumpAppliance` entity exposes features with defined data types and semantics. The EMS doesn't need a vendor manual to understand what `operationMode` means: it's specified, discoverable, and identical across every EEBUS-compatible manufacturer.
## Communication Model: Polling vs. Subscription
The communication model is also inverted. Modbus is **polling-based**: your EMS asks, the device answers, on a schedule you define. SPINE is **subscription-based**: you subscribe to the features you care about, and the device notifies you on change. For real-time energy coordination, the difference in latency and network load is noticeable.
## Security
Security is another axis. Modbus TCP has none natively. SHIP, the transport layer EEBUS runs on, includes TLS and device pairing as defined components of the spec, not optional additions.
## The Complexity Trade-off
The tradeoff is real: SPINE is more complex upfront. The data model takes time to internalize, and the SHIP pairing flow alone has caught more than one integration team off guard.
But the comparison isn't Modbus complexity versus EEBUS complexity for one device. It's **Modbus complexity per device, per manufacturer, per firmware version**, versus **EEBUS complexity once**.
## Why "Once" Matters
That "once" is where the stack choice matters. We built the KEO stack with both SHIP and SPINE fully abstracted: no pairing flow to implement, no data model to interpret, no spec ambiguity to chase down. The integration starts at the use case level.
---
If you're stuck on a SHIP pairing issue, a SPINE feature that's misbehaving, or sizing up the build-vs-buy decision for an EEBUS stack, feel free to reach out. Always up for comparing notes.
+26
View File
@@ -0,0 +1,26 @@
---
title: "Welcome to My Portfolio"
description: "First post to validate content collections setup"
publishDate: 2026-05-08
tags: ["meta", "announcement"]
category: "personal"
featured: true
draft: true
---
# Welcome
This is a sample blog post to validate that content collections are working correctly.
## Features
- Markdown content with frontmatter validation
- Type-safe schema with Zod
- Automatic type generation for TypeScript
## Code Example
```typescript
const greeting = "Hello, World!";
console.log(greeting);
```
This post will be replaced with real content later.
+31
View File
@@ -0,0 +1,31 @@
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
publishDate: z.coerce.date(),
author: z.string().default('David Aragón'),
tags: z.array(z.string()),
category: z.enum(['technical', 'business', 'personal']),
featured: z.boolean().default(false),
draft: z.boolean().default(false),
image: z.string().optional(),
}),
});
const projects = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
url: z.string().url(),
github: z.string().url().optional(),
status: z.enum(['active', 'development', 'completed']),
tags: z.array(z.string()),
startDate: z.coerce.date(),
featured: z.boolean().default(false),
image: z.string().optional(),
}),
});
export const collections = { blog, projects };
@@ -0,0 +1,48 @@
---
title: "Python Project Template"
description: "Opinionated Python project template with modern tooling: uv, ruff, mypy, pytest, Docker, and CI/CD ready"
url: "https://gitlab.impresion3d.pro/root/python-project-template"
status: "active"
tags: ["Python", "Docker", "CI/CD", "DevOps"]
startDate: 2026-05-01
featured: true
---
# Python Project Template
An opinionated Python project template designed for rapid development with modern tooling and infrastructure-as-code practices.
## Features
- **Modern Python tooling**: uv for fast dependency management, ruff for linting, mypy for type checking
- **Testing**: pytest with coverage reports
- **Docker ready**: Multi-stage Dockerfile optimized for Python projects
- **CI/CD pipeline**: Gitea Actions workflows for automated testing and deployment
- **Portainer integration**: Deploy to self-hosted infrastructure with one push
- **Development environment**: nox for task automation
## Why I Built This
After setting up multiple Python projects from scratch, I noticed I was repeating the same patterns: uv for dependencies, ruff for code quality, Docker for deployment, Gitea Actions for CI/CD. Each time I'd spend hours configuring these tools.
This template captures those decisions so new projects start with production-ready infrastructure on day one.
## Stack
- **Python 3.12+** with uv package manager
- **Quality tools**: ruff (linting), mypy (type checking), pytest (testing)
- **Containerization**: Docker with multi-stage builds
- **CI/CD**: Gitea Actions with automated testing and deployment
- **Orchestration**: Portainer stack deployment
## Use Cases
Perfect for:
- Web APIs (FastAPI, Flask)
- CLI tools
- Background workers
- Self-hosted applications
## Status
Active and maintained. I use this template for all new Python projects.
+21
View File
@@ -0,0 +1,21 @@
---
title: "Sample Project"
description: "Test project for content collections validation"
url: "https://example.com"
status: "development"
tags: ["test"]
startDate: 2026-05-08
featured: false
draft: true
---
# Sample Project
This is a placeholder project to validate content collections.
## Tech Stack
- Astro
- TypeScript
- Tailwind CSS
This will be replaced with real project case studies.
+72
View File
@@ -0,0 +1,72 @@
---
title: "WarrantyHub"
description: "Plataforma digital para gestionar todas las garantías de tus productos del hogar desde un solo lugar"
url: "https://warrantyhub.impresion3d.pro"
status: "development"
tags: ["React", "FastAPI", "PostgreSQL", "SaaS"]
startDate: 2026-04-15
featured: true
image: ""
---
# WarrantyHub
**Gestión digital de garantías para el hogar**
## El Problema
Todos tenemos el mismo problema: compramos electrodomésticos, gadgets, muebles... y guardamos los tickets y garantías en algún cajón. Cuando algo se rompe y necesitas reclamar:
- ❌ No encuentras el ticket
- ❌ No sabes si todavía está en garantía
- ❌ No tienes los datos del establecimiento
- ❌ El proceso de reclamación es un caos
**WarrantyHub resuelve esto.**
## La Solución
Una plataforma donde guardas todas tus garantías digitalmente:
**Digitalización rápida**: Foto del ticket y listo
**Recordatorios**: Te avisamos antes de que expire la garantía
**Organización**: Todos tus productos en un solo lugar
**Búsqueda fácil**: Encuentra cualquier garantía en segundos
**Proceso guiado**: Te ayudamos con el proceso de reclamación
## Stack Técnico
**Frontend:**
- React + TypeScript
- Tailwind CSS
- Vite
**Backend:**
- FastAPI (Python)
- PostgreSQL
- Docker + Portainer
**Features actuales:**
- Autenticación de usuarios
- Subida y digitalización de tickets (OCR)
- Gestión de productos y garantías
- Notificaciones de expiración
## Estado Actual
🚧 **En desarrollo activo**
Trabajando en:
- Sistema de recordatorios automáticos
- Mejora de OCR para extracción de datos
- Integración con tiendas para importar garantías automáticamente
## Por Qué Este Proyecto
Es un problema que yo mismo tenía. Y después de hablar con amigos y familia, descubrí que **todos** tienen el mismo problema.
No es sexy. No es AI generativa. Pero resuelve un problema real que la gente tiene cada día.
---
**Status:** Pronto en beta privada. [Suscríbete](/blog) para recibir invitación.
+49
View File
@@ -0,0 +1,49 @@
---
import '../styles/global.css';
export interface Props {
title: string;
description: string;
image?: string;
}
const { title, description, image } = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const socialImage = image ? new URL(image, Astro.site) : new URL('/images/og-default.jpg', Astro.site);
---
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} | David Aragón - Indie Builder</title>
<meta name="description" content={description}>
<link rel="canonical" href={canonicalURL} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalURL} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={socialImage} />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={canonicalURL} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImage} />
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body class="min-h-screen flex flex-col">
<slot />
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
---
const blogPostJsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "{title}",
"description": "{description}",
"image": "{socialImage}",
"datePublished": "{publishDate}",
"author": {
"@type": "Person",
"name": "David Aragón",
"url": "https://davidaragon.impresion3d.pro"
},
"publisher": {
"@type": "Person",
"name": "David Aragón",
"url": "https://davidaragon.impresion3d.pro"
},
"keywords": "{keywords}",
"articleSection": "{category}",
"inLanguage": "es-ES"
};
---
<!DOCTYPE html>
<html lang="es">
<head>
<!-- JSON-LD Blog Structured Data -->
<script type="application/ld+json" set:html={JSON.stringify(blogPostJsonLd)} />
</head>
<body>
<!-- Blog content -->
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
---
import BaseLayout from './BaseLayout.astro';
import Header from '../components/layout/Header.astro';
import Footer from '../components/layout/Footer.astro';
import Tag from '../components/ui/Tag.astro';
import { formatDate } from '../utils/dateFormat';
export interface Props {
title: string;
description: string;
url: string;
github?: string;
status: string;
tags: string[];
startDate: Date;
image?: string;
}
const { title, description, url, github, status, tags, startDate, image } = Astro.props;
const statusColors = {
active: 'text-accent-green',
development: 'text-accent-yellow',
completed: 'text-text-tertiary',
};
---
<BaseLayout title={title} description={description} image={image}>
<Header />
<main class="flex-1">
<article class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<header class="mb-12">
<div class="flex items-center gap-3 mb-4">
<span class={`text-sm font-semibold uppercase tracking-wide ${statusColors[status] || 'text-text-tertiary'}`}>
{status}
</span>
<span class="text-text-tertiary">•</span>
<time datetime={startDate.toISOString()} class="text-sm text-text-tertiary">
Desde {formatDate(startDate)}
</time>
</div>
<h1 class="text-4xl md:text-5xl font-bold mb-4">{title}</h1>
<p class="text-xl text-text-secondary mb-6">{description}</p>
<div class="flex flex-wrap gap-3">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
class="px-6 py-3 bg-primary hover:bg-primary/80 text-background rounded-lg
font-semibold transition-colors"
>
Ver Proyecto →
</a>
{github && (
<a
href={github}
target="_blank"
rel="noopener noreferrer"
class="px-6 py-3 bg-surface hover:bg-surface/80 text-text-primary rounded-lg
font-semibold border border-text-tertiary/20 transition-colors"
>
GitHub →
</a>
)}
</div>
</header>
{image && (
<div class="mb-12 rounded-xl overflow-hidden border border-text-tertiary/20">
<img
src={image}
alt={title}
class="w-full h-auto"
/>
</div>
)}
<div class="prose prose-invert prose-lg max-w-none">
<slot />
</div>
<footer class="mt-12 pt-8 border-t border-text-tertiary/20">
<h3 class="text-lg font-semibold mb-4">Tecnologías</h3>
<div class="flex flex-wrap gap-2">
{tags.map(tag => (
<Tag label={tag} variant="neutral" />
))}
</div>
</footer>
</article>
</main>
<Footer />
</BaseLayout>
+302
View File
@@ -0,0 +1,302 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Header from '../components/layout/Header.astro';
import Footer from '../components/layout/Footer.astro';
---
<BaseLayout
title="About"
description="Spanish builder. From goldsmith to photographer to developer. Building products and sharing the journey."
>
<Header />
<main class="flex-1 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<article class="prose prose-invert prose-lg max-w-none">
<h1 class="text-4xl font-bold mb-8">About Me</h1>
<div class="flex flex-col md:flex-row gap-8 mb-8 not-prose">
<div class="flex-shrink-0">
<img
src="/david-aragon.jpg"
alt="David Aragón"
class="w-48 h-48 rounded-full object-cover border-4 border-primary/20"
/>
</div>
<div class="flex-1">
<h2 class="text-2xl font-semibold mb-4 text-text-primary">Hi 👋</h2>
<p class="text-text-secondary text-lg leading-relaxed">
I'm David, a Spanish builder. I didn't start in tech — I was a goldsmith, then a professional
photographer for 13 years, instructor for LinkedIn Learning, and developer. Now I build products
that solve real problems.
</p>
</div>
</div>
<section class="mb-12">
<h2 class="text-3xl font-semibold mb-4">The Path</h2>
<p class="text-text-secondary leading-relaxed mb-4">
I started working with metal as a goldsmith. One day I got interested in photography,
and that obsession led me to build my own brand for 13 years. When I couldn't
afford the equipment I needed, I improvised. I built supports, modified
accessories, made what I had work.
</p>
<p class="text-text-secondary leading-relaxed mb-4">
That same drive led me to learn Python, build my own 3D printer from scratch,
work at Qloudea developing internal tools while managing social media
and providing technical support. Then Python developer at NTT Data, and now I work at KEO
Connectivity with IoT protocols for energy systems (EEBUS).
</p>
<p class="text-text-secondary leading-relaxed mb-4">
<strong class="text-text-primary">The pattern is always the same:</strong> identify a
problem, obsess over solving it, learn whatever it takes to fix it.
</p>
</section>
<section class="mb-12">
<h2 class="text-3xl font-semibold mb-4">What I'm Doing Now</h2>
<p class="text-text-secondary leading-relaxed mb-4">
I work full-time at KEO as Field Application Engineer, where I handle technical
documentation, workflow automation, internal tools, and customer-facing support.
</p>
<p class="text-text-secondary leading-relaxed mb-4">
<em class="text-text-tertiary text-sm">
Last site update: 2026-07-09 (build sha visible in the CI run that produced this
version of the page).
</em>
</p>
<p class="text-text-secondary leading-relaxed mb-4">
In parallel, I'm building <strong class="text-primary">WarrantyHub</strong> — a platform
for end users to manage warranties for their home products from one place.
It's a problem I had myself, and I discovered all my friends and family have the same problem.
</p>
<p class="text-text-secondary leading-relaxed mb-4">
I also work on other experimental projects: productivity CLIs, automation tools,
Discord bots, and templates to accelerate Python development.
</p>
</section>
<section class="mb-12">
<h2 class="text-3xl font-semibold mb-4">Current Projects</h2>
<div class="space-y-4">
<div class="bg-surface p-5 rounded-lg border border-text-tertiary/20">
<h3 class="text-lg font-semibold text-primary mb-2">WarrantyHub</h3>
<p class="text-text-secondary text-sm">
Digital home warranty manager. Self-hosted PWA with freemium model.
Stack: React + FastAPI + PostgreSQL.
</p>
</div>
<div class="bg-surface p-5 rounded-lg border border-text-tertiary/20">
<h3 class="text-lg font-semibold text-primary mb-2">Python Project Template</h3>
<p class="text-text-secondary text-sm">
Opinionated template for Python projects: uv, ruff, mypy, pytest, Docker, Portainer.
CI/CD with Gitea Actions.
</p>
</div>
<div class="bg-surface p-5 rounded-lg border border-text-tertiary/20">
<h3 class="text-lg font-semibold text-primary mb-2">Hermes Stack</h3>
<p class="text-text-secondary text-sm">
Hermes Agent gateway stack for QNAP/Portainer. Automation with AI agents
for infrastructure management.
</p>
</div>
<div class="bg-surface p-5 rounded-lg border border-text-tertiary/20">
<h3 class="text-lg font-semibold text-primary mb-2">Other Experiments</h3>
<p class="text-text-secondary text-sm">
Discord bots (iRacing), automation tools (Portainer backups),
utility scripts (FLAC tagging, cookie extraction), and more.
</p>
</div>
</div>
</section>
<section class="mb-12">
<h2 class="text-3xl font-semibold mb-4">Self-Hosted Infrastructure</h2>
<p class="text-text-secondary leading-relaxed mb-4">
All my projects run on local self-hosted infrastructure. I prefer to have complete
control over the stack and keep costs low while experimenting.
</p>
<div class="bg-surface p-6 rounded-lg border border-primary/20">
<h3 class="text-xl font-semibold mb-4 text-primary">Current Stack</h3>
<ul class="space-y-2 text-text-secondary">
<li class="flex items-start">
<span class="text-primary mr-2">→</span>
<span><strong class="text-text-primary">Local physical server</strong> -
Main Docker host</span>
</li>
<li class="flex items-start">
<span class="text-primary mr-2">→</span>
<span><strong class="text-text-primary">Portainer</strong> - Stack and container
orchestration</span>
</li>
<li class="flex items-start">
<span class="text-primary mr-2">→</span>
<span><strong class="text-text-primary">Gitea</strong> -
Git hosting + Docker registry + CI/CD</span>
</li>
<li class="flex items-start">
<span class="text-primary mr-2">→</span>
<span><strong class="text-text-primary">MinIO</strong> - Object storage (S3-compatible)</span>
</li>
<li class="flex items-start">
<span class="text-primary mr-2">→</span>
<span><strong class="text-text-primary">Nginx Proxy Manager</strong> - Reverse proxy
with automatic SSL (Let's Encrypt)</span>
</li>
<li class="flex items-start">
<span class="text-primary mr-2">→</span>
<span><strong class="text-text-primary">PostgreSQL</strong> - Main database
for applications</span>
</li>
</ul>
<p class="text-text-secondary text-sm mt-4">
Fully automated CI/CD: push to main → build image → push to registry →
deploy to Portainer. Everything on local infrastructure.
</p>
</div>
</section>
<section class="mb-12">
<h2 class="text-3xl font-semibold mb-4">Why Build in Public</h2>
<ul class="space-y-3">
<li class="flex items-start">
<span class="text-primary mr-2">→</span>
<span class="text-text-secondary">
<strong class="text-text-primary">Accountability:</strong> It's harder to quit
when you share progress publicly
</span>
</li>
<li class="flex items-start">
<span class="text-primary mr-2">→</span>
<span class="text-text-secondary">
<strong class="text-text-primary">Teaching:</strong> I've always been an instructor
(LinkedIn Learning, Podcast CulturaNAS 2000+ subs). Sharing what I learn is part of who I am
</span>
</li>
<li class="flex items-start">
<span class="text-primary mr-2">→</span>
<span class="text-text-secondary">
<strong class="text-text-primary">Community:</strong> I want to connect with other
Spanish builders on the same journey
</span>
</li>
</ul>
</section>
<section class="mb-12">
<h2 class="text-3xl font-semibold mb-4">Tech Stack</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 not-prose">
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">Python</div>
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">FastAPI</div>
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">React</div>
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">TypeScript</div>
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">PostgreSQL</div>
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">Docker</div>
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">Tailwind CSS</div>
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">Astro</div>
<div class="bg-surface px-4 py-3 rounded-lg border border-text-tertiary/20">n8n</div>
</div>
</section>
<section class="mb-12">
<h2 class="text-3xl font-semibold mb-4">Experience</h2>
<div class="space-y-6">
<div class="bg-surface p-6 rounded-lg border border-text-tertiary/20">
<div class="flex items-start justify-between mb-2">
<h3 class="text-xl font-semibold text-primary">Field Application Engineer</h3>
<span class="text-sm text-text-tertiary whitespace-nowrap ml-4">2023 - Present</span>
</div>
<p class="text-text-secondary mb-2">KEO Connectivity (Germany)</p>
<p class="text-text-secondary text-sm">
EEBUS protocol for IoT energy systems (e-mobility, smart homes,
heat pumps). Technical documentation, automation with n8n, internal tools,
technical support.
</p>
</div>
<div class="bg-surface p-6 rounded-lg border border-text-tertiary/20">
<div class="flex items-start justify-between mb-2">
<h3 class="text-xl font-semibold text-primary">Python Developer</h3>
<span class="text-sm text-text-tertiary whitespace-nowrap ml-4">2022 - 2023</span>
</div>
<p class="text-text-secondary mb-2">NTT Data</p>
<p class="text-text-secondary text-sm">
Python development, Google Cloud, Kubernetes, BigQuery. Data analysis and BigData.
</p>
</div>
<div class="bg-surface p-6 rounded-lg border border-text-tertiary/20">
<div class="flex items-start justify-between mb-2">
<h3 class="text-xl font-semibold text-primary">ICT Instructor + Developer</h3>
<span class="text-sm text-text-tertiary whitespace-nowrap ml-4">2013 - 2022</span>
</div>
<p class="text-text-secondary mb-2">Qloudea + LinkedIn Learning</p>
<p class="text-text-secondary text-sm">
Development of internal tools (HTML5, CSS, JavaScript, Python, Django, Flutter).
Official instructor on NAS servers for professionals. Social media management, YouTube,
product photography.
</p>
</div>
<div class="bg-surface p-6 rounded-lg border border-text-tertiary/20">
<div class="flex items-start justify-between mb-2">
<h3 class="text-xl font-semibold text-primary">Professional Photographer</h3>
<span class="text-sm text-text-tertiary whitespace-nowrap ml-4">2003 - 2016</span>
</div>
<p class="text-text-secondary mb-2">Own Brand</p>
<p class="text-text-secondary text-sm">
13 years managing my own photography brand. Creator of CulturaNAS Podcast
(2000+ subscribers).
</p>
</div>
</div>
</section>
<section>
<h2 class="text-3xl font-semibold mb-4">Connect With Me</h2>
<p class="text-text-secondary mb-6">
Always open to talking with other builders, people interested in EEBUS/IoT,
or just curious people about building products.
</p>
<div class="flex flex-wrap gap-4 not-prose">
<a
href="https://twitter.com/davidaragon"
target="_blank"
rel="noopener noreferrer"
class="bg-primary/10 hover:bg-primary/20 text-primary px-6 py-3 rounded-lg
font-medium transition-colors border border-primary/20"
>
X (Twitter)
</a>
<a
href="https://linkedin.com/in/david-aragon-galiana"
target="_blank"
rel="noopener noreferrer"
class="bg-primary/10 hover:bg-primary/20 text-primary px-6 py-3 rounded-lg
font-medium transition-colors border border-primary/20"
>
LinkedIn
</a>
<a
href="https://github.com/davidaragon"
target="_blank"
rel="noopener noreferrer"
class="bg-primary/10 hover:bg-primary/20 text-primary px-6 py-3 rounded-lg
font-medium transition-colors border border-primary/20"
>
GitHub
</a>
<a
href="mailto:david@impresion3d.pro"
class="bg-secondary/10 hover:bg-secondary/20 text-secondary px-6 py-3 rounded-lg
font-medium transition-colors border border-secondary/20"
>
Email
</a>
</div>
</section>
</article>
</main>
<Footer />
</BaseLayout>
+65
View File
@@ -0,0 +1,65 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/layout/Header.astro';
import Footer from '../../components/layout/Footer.astro';
export async function getStaticPaths() {
const blogEntries = await getCollection('blog', ({ data }) => !data.draft);
return blogEntries.map(entry => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content } = await entry.render();
---
<BaseLayout
title={entry.data.title}
description={entry.data.description}
>
<Header />
<main class="flex-1 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<article>
<header class="mb-8">
<time class="text-sm text-text-tertiary block mb-2">
{entry.data.publishDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
<h1 class="text-4xl font-bold mb-4">{entry.data.title}</h1>
<p class="text-xl text-text-secondary mb-4">
{entry.data.description}
</p>
<div class="flex flex-wrap gap-2">
{entry.data.tags.map((tag) => (
<span class="inline-block px-3 py-1 rounded-full text-sm font-medium border bg-text-tertiary/10 text-text-secondary border-text-tertiary/20">
{tag}
</span>
))}
</div>
</header>
<div class="prose prose-invert prose-lg max-w-none">
<Content />
</div>
<footer class="mt-12 pt-8 border-t border-text-tertiary/20">
<a
href="/blog"
class="text-primary hover:text-secondary transition-colors font-medium"
>
← Back to all posts
</a>
</footer>
</article>
</main>
<Footer />
</BaseLayout>
+62
View File
@@ -0,0 +1,62 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/layout/Header.astro';
import Footer from '../../components/layout/Footer.astro';
const allPosts = await getCollection('blog', ({ data }) => !data.draft);
const sortedPosts = allPosts.sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()
);
---
<BaseLayout
title="Blog"
description="Thoughts on building products, technical learnings, and the indie builder journey"
>
<Header />
<main class="flex-1 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 class="text-4xl font-bold mb-8">Blog</h1>
<div class="space-y-8">
{sortedPosts.map((post) => (
<article class="bg-surface rounded-lg border border-text-tertiary/20 p-6 hover:border-primary/40 transition-colors">
<a href={`/blog/${post.slug}`} class="block">
<time class="text-sm text-text-tertiary block mb-2">
{post.data.publishDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
<h2 class="text-2xl font-semibold mb-3 text-text-primary hover:text-primary transition-colors">
{post.data.title}
</h2>
<p class="text-text-secondary mb-4">
{post.data.description}
</p>
<div class="flex flex-wrap gap-2 mb-4">
{post.data.tags.map((tag) => (
<span class="inline-block px-3 py-1 rounded-full text-sm font-medium border bg-text-tertiary/10 text-text-secondary border-text-tertiary/20">
{tag}
</span>
))}
</div>
<span class="text-primary font-medium">Read more →</span>
</a>
</article>
))}
</div>
{sortedPosts.length === 0 && (
<p class="text-text-secondary text-center py-12">
No posts yet. Check back soon!
</p>
)}
</main>
<Footer />
</BaseLayout>
+188 -14
View File
@@ -1,17 +1,191 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro';
import Header from '../components/layout/Header.astro';
import Footer from '../components/layout/Footer.astro';
import Card from '../components/ui/Card.astro';
import Tag from '../components/ui/Tag.astro';
import { getCollection } from 'astro:content';
import { formatDate } from '../utils/dateFormat';
// Fetch featured projects and recent blog posts
const projects = (await getCollection('projects'))
.filter(p => !p.data.draft && p.data.featured)
.slice(0, 3);
const blogPosts = (await getCollection('blog'))
.filter(p => !p.data.draft)
.sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime())
.slice(0, 3);
---
--- <BaseLayout
title="Home"
description="David Aragón's portfolio - Builder creating products and sharing the journey"
>
<Header />
<main class="flex-1">
<!-- Hero Section -->
<section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div class="flex flex-col md:flex-row items-center gap-12">
<div class="flex-shrink-0">
<img
src="/david-aragon.jpg"
alt="David Aragón"
class="w-48 h-48 md:w-64 md:h-64 rounded-full object-cover border-4 border-primary/20"
/>
</div>
<div class="flex-1 text-center md:text-left">
<h1 class="text-4xl md:text-5xl font-bold mb-6">
Hi, I'm <span class="text-primary">David Aragón</span>
</h1>
<p class="text-xl text-text-secondary mb-6 leading-relaxed">
Builder creating products that solve real problems.
From goldsmith to photographer to developer. Now building in public.
</p>
<p class="text-lg text-text-secondary mb-8">
Currently working on <strong class="text-primary">WarrantyHub</strong> -
digital warranty management for home users.
</p>
<div class="flex flex-wrap justify-center md:justify-start gap-4">
<a
href="/blog"
class="bg-primary hover:bg-primary/80 text-background px-8 py-3 rounded-lg
font-semibold transition-colors"
>
Read Blog
</a>
<a
href="/about"
class="bg-surface hover:bg-surface/80 text-text-primary px-8 py-3 rounded-lg
font-semibold border border-text-tertiary/20 transition-colors"
>
About Me
</a>
</div>
</div>
</div>
</section>
<html lang="en"> <!-- Latest Blog Posts -->
<head> <section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<meta charset="utf-8" /> <div class="flex items-center justify-between mb-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <h2 class="text-3xl font-bold">Latest Posts</h2>
<link rel="icon" href="/favicon.ico" /> <a
<meta name="viewport" content="width=device-width" /> href="/blog"
<meta name="generator" content={Astro.generator} /> class="text-primary hover:text-secondary transition-colors font-medium"
<title>Astro</title> >
</head> View all →
<body> </a>
<h1>Astro</h1> </div>
</body>
</html> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{blogPosts.length > 0 ? (
blogPosts.map((post) => (
<Card href={`/blog/${post.slug}`}>
<div class="mb-4">
<time class="text-sm text-text-tertiary">
{formatDate(post.data.publishDate)}
</time>
</div>
<h3 class="text-xl font-semibold mb-3 text-text-primary group-hover:text-primary transition-colors">
{post.data.title}
</h3>
<p class="text-text-secondary mb-4 line-clamp-3">
{post.data.description}
</p>
<div class="flex flex-wrap gap-2 mb-4">
{post.data.tags?.slice(0, 3).map((tag: string) => (
<Tag>{tag}</Tag>
))}
</div>
<div class="text-sm">
<span class="text-primary font-medium">Read more →</span>
</div>
</Card>
))
) : (
<div class="col-span-full text-center py-12">
<p class="text-text-secondary">
Posts coming soon. Subscribe to receive updates.
</p>
</div>
)}
</div>
</section>
<!-- Featured Projects (if any) -->
{projects.length > 0 && (
<section class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div class="flex items-center justify-between mb-8">
<h2 class="text-3xl font-bold">Projects</h2>
<a
href="/projects"
class="text-primary hover:text-secondary transition-colors font-medium"
>
View all →
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<Card href={`/projects/${project.slug}`}>
<div class="mb-4">
<span class="text-sm text-text-tertiary capitalize">
{project.data.status}
</span>
</div>
<h3 class="text-xl font-semibold mb-3 text-text-primary group-hover:text-primary transition-colors">
{project.data.title}
</h3>
<p class="text-text-secondary mb-4">
{project.data.description}
</p>
<div class="flex flex-wrap gap-2">
{project.data.tags?.slice(0, 4).map((tag: string) => (
<Tag>{tag}</Tag>
))}
</div>
</Card>
))}
</div>
</section>
)}
<!-- Newsletter CTA -->
<section class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div class="bg-gradient-to-r from-primary/10 to-secondary/10 border border-primary/20
rounded-2xl p-8 md:p-12 text-center">
<h2 class="text-3xl font-bold mb-4">Stay Updated</h2>
<p class="text-text-secondary text-lg mb-8 max-w-2xl mx-auto">
Get occasional updates about launches, learnings, and technical decisions.
No spam, no BS - just real builder content.
</p>
<form
class="max-w-md mx-auto flex flex-col sm:flex-row gap-4"
method="POST"
action="/api/subscribe"
>
<input
type="email"
name="email"
placeholder="your@email.com"
required
class="flex-1 px-6 py-3 rounded-lg bg-surface border border-text-tertiary/20
text-text-primary placeholder-text-tertiary focus:outline-none
focus:border-primary transition-colors"
/>
<button
type="submit"
class="bg-primary hover:bg-primary/80 text-background px-8 py-3 rounded-lg
font-semibold transition-colors whitespace-nowrap"
>
Subscribe
</button>
</form>
</div>
</section>
</main>
<Footer />
</BaseLayout>
+21
View File
@@ -0,0 +1,21 @@
---
import { getCollection } from 'astro:content';
import ProjectLayout from '../../layouts/ProjectLayout.astro';
export async function getStaticPaths() {
const projects = await getCollection('projects');
return projects
.filter(p => !p.data.draft)
.map(project => ({
params: { slug: project.slug },
props: { project },
}));
}
const { project } = Astro.props;
const { Content } = await project.render();
---
<ProjectLayout {...project.data}>
<Content />
</ProjectLayout>
+127
View File
@@ -0,0 +1,127 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/layout/Header.astro';
import Footer from '../../components/layout/Footer.astro';
import Card from '../../components/ui/Card.astro';
import Tag from '../../components/ui/Tag.astro';
import { getCollection } from 'astro:content';
const allProjects = (await getCollection('projects'))
.filter(p => !p.data.draft)
.sort((a, b) => b.data.startDate.getTime() - a.data.startDate.getTime());
const featured = allProjects.filter(p => p.data.featured);
const active = allProjects.filter(p => p.data.status === 'active' && !p.data.featured);
const others = allProjects.filter(p => p.data.status !== 'active' && !p.data.featured);
---
<BaseLayout
title="Proyectos"
description="Portfolio de productos SaaS: WarrantyHub, Garage61 API, y más proyectos en desarrollo"
>
<Header />
<main class="flex-1 max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<header class="mb-12">
<h1 class="text-4xl font-bold mb-4">Proyectos</h1>
<p class="text-xl text-text-secondary max-w-3xl">
Portfolio de productos SaaS construidos en público. Desde ideas hasta
productos escalables generando revenue recurrente.
</p>
</header>
{featured.length > 0 && (
<section class="mb-16">
<h2 class="text-2xl font-semibold mb-6 text-primary">🌟 Proyectos Destacados</h2>
<div class="grid md:grid-cols-2 gap-6">
{featured.map(project => (
<Card href={`/projects/${project.slug}`} class="flex flex-col">
{project.data.image && (
<img
src={project.data.image}
alt={project.data.title}
class="w-full h-48 object-cover rounded-t-lg -m-6 mb-4"
/>
)}
<h3 class="text-2xl font-semibold mb-2 text-text-primary">
{project.data.title}
</h3>
<p class="text-text-secondary mb-4 flex-1">
{project.data.description}
</p>
<div class="flex flex-wrap gap-2 mb-4">
{project.data.tags.slice(0, 4).map(tag => (
<Tag label={tag} variant="primary" />
))}
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-accent-green font-medium capitalize">
{project.data.status}
</span>
<span class="text-primary text-sm font-medium">
Ver proyecto →
</span>
</div>
</Card>
))}
</div>
</section>
)}
{active.length > 0 && (
<section class="mb-16">
<h2 class="text-2xl font-semibold mb-6">Proyectos Activos</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{active.map(project => (
<Card href={`/projects/${project.slug}`}>
<h3 class="text-xl font-semibold mb-2 text-text-primary">
{project.data.title}
</h3>
<p class="text-text-secondary mb-4">
{project.data.description}
</p>
<div class="flex flex-wrap gap-2 mb-4">
{project.data.tags.slice(0, 3).map(tag => (
<Tag label={tag} variant="neutral" />
))}
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-accent-green font-medium">Activo</span>
<span class="text-primary text-sm font-medium">Ver →</span>
</div>
</Card>
))}
</div>
</section>
)}
{others.length > 0 && (
<section>
<h2 class="text-2xl font-semibold mb-6">Otros Proyectos</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{others.map(project => (
<Card href={`/projects/${project.slug}`}>
<h3 class="text-xl font-semibold mb-2 text-text-primary">
{project.data.title}
</h3>
<p class="text-text-secondary mb-4">
{project.data.description}
</p>
<div class="flex flex-wrap gap-2 mb-4">
{project.data.tags.slice(0, 3).map(tag => (
<Tag label={tag} variant="neutral" />
))}
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-text-tertiary capitalize">
{project.data.status}
</span>
<span class="text-primary text-sm font-medium">Ver →</span>
</div>
</Card>
))}
</div>
</section>
)}
</main>
<Footer />
</BaseLayout>
+7
View File
@@ -0,0 +1,7 @@
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
}
+6
View File
@@ -0,0 +1,6 @@
export function getReadingTime(content: string): string {
const wordsPerMinute = 200;
const words = content.trim().split(/\s+/).length;
const minutes = Math.ceil(words / wordsPerMinute);
return `${minutes} min lectura`;
}