54 Commits

Author SHA1 Message Date
root f716b394c2 fix(ci): add full docker PATH in SSH pre-flight and deploy commands
The NAS 'admin' user has docker at
/share/CACHEDEV1_DATA/.qpkg/container-station/usr/bin/docker which
isn't in the default PATH for non-interactive SSH. Set the PATH
explicitly in both the pre-flight check and the actual deploy.
2026-07-10 12:40:53 +00:00
root 94477d46ef fix(ci): extract host and port from NAS_HOST secret
NAS_HOST can be either 'host' or 'host:port'. The previous workflow
passed the whole value as the SSH target, which fails with
'Could not resolve hostname 192.168.1.21:222' when the port is included.

Parse the secret into NAS_SSH_HOST and NAS_SSH_PORT and pass them
explicitly to ssh.
2026-07-10 12:39:52 +00:00
root 6ff7bc5fda fix(ci): deploy via SSH to NAS instead of Portainer API
act_runner v0.6.1 does not propagate the runner's network namespace
to job containers, so the job can never resolve 'portainer' or reach
'192.168.1.30:9000'. After trying every combination of
container.network_mode / container.network / options in the runner
config, the only reliable solution is to skip the in-cluster Portainer
API call and do the deploy via SSH directly on the NAS.

- New secrets: NAS_SSH_KEY, NAS_HOST, NAS_USER.
- The new 'Redeploy stack on Portainer via SSH' step writes the SSH key
  to ~/.ssh, then pipes docker-compose.prod.yml to
  'docker compose -p davidaragon-portfolio -f - up -d' over SSH.
- The old Portainer-API step is removed.

Smoke-test at the end hits the public port on the host (3001) and
logs the HTTP code; we don't fail the job if the public proxy
upstream isn't reachable from the runner's network.
2026-07-10 12:37:16 +00:00
root 89150ed41f fix(ci): hardcode PORTAINER_URL to http://portainer:9000
Drop the secret indirection and just use the service name directly.
All services share the portainer_default Docker network so 'portainer'
resolves to Portainer from any container in that network.
2026-07-10 12:19:38 +00:00
root 687e4d9f2d fix(ci): use container name 'portainer' instead of IP
All services (Gitea, the 3 runners and Portainer itself) are in the
same Docker network 'portainer_default', so the service name 'portainer'
resolves directly from inside any container in that network. The previous
auto-discovery dance with IPs and routes is no longer needed.

- Set the PORTAINER_URL secret to 'http://portainer:9000'.
- Replace the entire 'Step 0' auto-discovery with a simple check that
  the configured URL is reachable.
- Drop the debug Step 0a that was added during troubleshooting.

The runner's job container can now reach Portainer the same way the
runner itself does — by name.
2026-07-10 12:17:40 +00:00
root d14b719bd2 debug(ci): print hostname/route on auto-discover failure
- Drop 'set -euo pipefail' from the redeploy step so the auto-discovery
  loop and the diagnostic echos can run to completion even if a probe
  fails or a command returns empty output.
- On failure to find Portainer, also print the container's hostname,
  hostname -I, and ip route to stderr. This is what we'll need if the
  host-net job container approach doesn't work and we need to debug
  further.
2026-07-10 11:41:54 +00:00
root dcfb7805cd debug(ci): print job container network info before auto-discovery
Adds Step 0a that prints hostname -I, ip route, and a direct curl to
192.168.1.30:9000. This is to verify whether the runner external (in
network_mode: host) is actually propagating host networking to the job
containers it spawns. Symptom: deploy step fails with 'Failed to
connect to 192.168.1.30 port 9000' even from the external runner.
To be removed once we know.
2026-07-10 11:36:59 +00:00
root 06ad8edf18 fix(ci): auto-discover reachable Portainer URL from job container
CI/CD Pipeline / Build & Deploy (pull_request) Successful in 21s
The act_runner on the QNAP creates an ephemeral docker bridge per job.
That network can't always reach the canonical PORTAINER_URL
(http://192.168.1.30:9000) — first deploy run failed with
'Failed to connect to 192.168.1.30 port 9000 after 130317 ms'.

New step 0 probes several candidate URLs (container's own IPs, default
gateways, then the secret value as fallback) against /api/status and
uses the first one that returns 200. The reachable host is then reused
for the DELETE+CREATE calls and the smoke-test on :3001.
2026-07-10 11:21:52 +00:00
root fc1fd96145 Merge pull request #4 from feat/portainer-redeploy-step
CI/CD Pipeline / Build & Deploy (push) Successful in 46s
PR auto-merged by Hermes after self-review.
2026-07-10 13:12:03 +02:00
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
wh-leader 85143c0b05 feat: initialize Astro project with Tailwind and TypeScript
- Astro 5.0 with TypeScript strict mode
- Tailwind CSS 3.4 configured with custom design system
- Project structure created (components, layouts, content, styles)
- Sitemap and RSS integrations added
- Build verified successfully

Fixes dependency conflict by using Astro 5 instead of 6
2026-05-10 11:29:24 +02:00
wh-leader 8c11722608 docs: add orchestration summary with all created tasks and status 2026-05-08 12:58:01 +02:00
wh-leader f252241f9e docs: add implementation roadmap with task dependency graph and acceptance gates 2026-05-08 12:57:12 +02:00
wh-leader e7fd109a7a docs: add complete portfolio architecture design 2026-05-08 12:49:22 +02:00
50 changed files with 10301 additions and 2 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
.git
.gitignore
.vscode
.idea
*.md
!README.md
.env
.env.*
dist
.astro
+114
View File
@@ -0,0 +1,114 @@
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 via SSH
if: github.event_name == 'workflow_dispatch' && inputs.deploy == true
env:
NAS_SSH_KEY: ${{ secrets.NAS_SSH_KEY }}
NAS_HOST: ${{ secrets.NAS_HOST }}
NAS_USER: ${{ secrets.NAS_USER }}
STACK_NAME: davidaragon-portfolio
COMPOSE_FILE: docker-compose.prod.yml
run: |
# The act_runner job container can't reach 'portainer' because it
# doesn't share the runner's network namespace in v0.6.1. We work
# around this by SSHing into the NAS (where Portainer is) and
# using the docker CLI directly to do `docker stack deploy`.
set -euo pipefail
mkdir -p ~/.ssh
echo "$NAS_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "--- Pre-flight: ensure NAS is reachable ---"
# NAS_HOST can be either "host" or "host:port"
NAS_SSH_PORT=$(echo "$NAS_HOST" | grep -q ':' && echo "${NAS_HOST##*:}" || echo "22")
NAS_SSH_HOST="${NAS_HOST%%:*}"
echo "Using NAS_SSH_HOST=$NAS_SSH_HOST NAS_SSH_PORT=$NAS_SSH_PORT"
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -p "$NAS_SSH_PORT" \
"$NAS_USER@$NAS_SSH_HOST" \
'export PATH=/share/CACHEDEV1_DATA/.qpkg/container-station/usr/bin:$PATH; hostname && docker version --format "{{.Server.Version}}"' \
| head
echo "--- Reading docker-compose.prod.yml from the repo ---"
if [ ! -f "$COMPOSE_FILE" ]; then
echo "ERROR: $COMPOSE_FILE not found in repo root" >&2
exit 1
fi
echo "--- Redeploying stack '$STACK_NAME' on the NAS ---"
# The NAS has Portainer and docker compose (v2) available. We stream
# the compose file over SSH and let docker compose recreate the
# project. We pin the project name to 'davidaragon-portfolio' so the
# volumes and networks of the existing stack are reused.
cat "$COMPOSE_FILE" | ssh -o StrictHostKeyChecking=no -p "$NAS_SSH_PORT" \
"$NAS_USER@$NAS_SSH_HOST" \
"export PATH=/share/CACHEDEV1_DATA/.qpkg/container-station/usr/bin:\$PATH; cd /tmp && docker compose -p '$STACK_NAME' -f - up -d"
echo "--- Stack '$STACK_NAME' redeployed. Smoke-test: ---"
sleep 5
curl -sS -o /dev/null -w 'http://localhost:3001/ -> HTTP=%{http_code}\n' \
--max-time 5 http://localhost:3001/ || true
echo "--- Done ---"
+24
View File
@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
+527
View File
@@ -0,0 +1,527 @@
# Personal Portfolio Architecture - davidaragon.impresion3d.pro
**Project**: Personal Tech Portfolio & Dev Blog
**Tech Stack**: Astro + Tailwind CSS
**Deploy**: Docker + Portainer on 192.168.1.30
**Investment**: €8,100 (4-week sprint)
**Strategic Value**: €45-95K 3-year value (477-1,118% ROI)
---
## 1. Technology Stack
### Core Framework
- **Astro 4.x**: Static site generator optimized for content-first sites
- Ultra-fast static HTML generation
- Markdown-first content workflow
- Minimal JavaScript (island architecture)
- Built-in SEO optimization
- Zero-config TypeScript support
### Styling & UI
- **Tailwind CSS 3.x**: Utility-first CSS framework
- Dark mode optimized
- Responsive design system
- Custom color palette
- Typography plugin for blog content
### Content Management
- **Astro Content Collections**: Type-safe content management
- Blog posts (Markdown + frontmatter)
- Project case studies
- Schema validation with Zod
- Automatic type generation
### Build & Deploy
- **Docker**: Containerized deployment
- Nginx as static file server
- Multi-stage build (node build + nginx serve)
- Optimized image size (<50MB)
- **Portainer**: Container orchestration on 192.168.1.30
- Stack deployment via docker-compose
- Environment variable management
- Persistent volume for logs
---
## 2. Project Structure
```
davidaragon-portfolio/
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── layout/
│ │ │ ├── Header.astro
│ │ │ ├── Footer.astro
│ │ │ └── Navigation.astro
│ │ ├── blog/
│ │ │ ├── PostCard.astro
│ │ │ ├── PostList.astro
│ │ │ └── PostLayout.astro
│ │ ├── projects/
│ │ │ ├── ProjectCard.astro
│ │ │ └── ProjectGrid.astro
│ │ └── ui/
│ │ ├── Button.astro
│ │ ├── Card.astro
│ │ └── Tag.astro
│ ├── content/ # Content collections
│ │ ├── config.ts # Collection schemas
│ │ ├── blog/ # Blog posts (Markdown)
│ │ │ ├── building-in-public.md
│ │ │ ├── spanish-saas.md
│ │ │ └── warrantyhub-pwa.md
│ │ └── projects/ # Project case studies
│ │ ├── warrantyhub.md
│ │ └── garage61.md
│ ├── layouts/ # Page layouts
│ │ ├── BaseLayout.astro
│ │ ├── BlogLayout.astro
│ │ └── ProjectLayout.astro
│ ├── pages/ # Routes (file-based routing)
│ │ ├── index.astro # Homepage
│ │ ├── about.astro # About page
│ │ ├── projects/
│ │ │ ├── index.astro # Projects listing
│ │ │ └── [slug].astro # Individual project
│ │ ├── blog/
│ │ │ ├── index.astro # Blog listing
│ │ │ └── [slug].astro # Individual post
│ │ └── rss.xml.ts # RSS feed generator
│ ├── styles/ # Global styles
│ │ ├── global.css # Base styles + Tailwind
│ │ └── syntax.css # Code syntax highlighting
│ └── utils/ # Helper functions
│ ├── dateFormat.ts
│ └── readingTime.ts
├── public/ # Static assets
│ ├── images/
│ │ ├── avatar.jpg
│ │ └── projects/
│ ├── favicon.ico
│ └── robots.txt
├── .docker/
│ ├── Dockerfile # Multi-stage build
│ ├── nginx.conf # Nginx configuration
│ └── docker-compose.yml # Portainer stack
├── astro.config.mjs # Astro configuration
├── tailwind.config.cjs # Tailwind configuration
├── tsconfig.json # TypeScript configuration
└── package.json # Dependencies
```
---
## 3. Content Collections Schema
### Blog Posts
```typescript
// src/content/config.ts
const blog = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
publishDate: z.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(),
}),
});
```
### Projects
```typescript
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.date(),
featured: z.boolean().default(false),
image: z.string().optional(),
}),
});
```
---
## 4. Design System
### Color Palette (Dark Mode Optimized)
```css
/* Tailwind config */
colors: {
background: '#0a0e27', // Deep navy
surface: '#131729', // Card backgrounds
primary: '#60a5fa', // Blue accent
secondary: '#a78bfa', // Purple accent
text: {
primary: '#f1f5f9', // Light text
secondary: '#94a3b8', // Muted text
tertiary: '#64748b', // Subtle text
},
accent: {
green: '#34d399', // Success
red: '#f87171', // Error
yellow: '#fbbf24', // Warning
}
}
```
### Typography
- **Headings**: Inter (font-weight: 600-800)
- **Body**: Inter (font-weight: 400-500)
- **Code**: JetBrains Mono (font-weight: 400)
- **Scale**:
- H1: 3rem (48px)
- H2: 2.25rem (36px)
- H3: 1.875rem (30px)
- Body: 1rem (16px)
- Small: 0.875rem (14px)
### Component System
- **Cards**: Glassmorphism effect with subtle backdrop blur
- **Buttons**: Hover transitions, focus states
- **Navigation**: Sticky header with scroll-based styling
- **Links**: Underline on hover, smooth transitions
- **Code blocks**: Syntax highlighting with Shiki (VS Code Dark+ theme)
---
## 5. SEO Strategy
### Meta Tags
- **Title template**: `{page} | David Aragón - Indie Builder`
- **Description**: Dynamic per page
- **Open Graph**: Images for all blog posts and projects
- **Twitter Cards**: Summary with large image
### Sitemap
- Auto-generated via Astro integration
- Includes all pages, blog posts, projects
- Priority: Homepage (1.0), Blog/Projects (0.8), Posts (0.6)
### RSS Feed
- Full-text feed at `/rss.xml`
- Includes title, description, content, publish date
- Auto-updated on build
### Performance
- **Target Lighthouse score**: 95+ across all metrics
- **Core Web Vitals**:
- LCP: <2.5s
- FID: <100ms
- CLS: <0.1
---
## 6. Docker Deployment Architecture
### Multi-Stage Dockerfile
```dockerfile
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY .docker/nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
### Nginx Configuration
- Serve static files from `/usr/share/nginx/html`
- Gzip compression for text assets
- Cache-Control headers (1 year for assets, 1 hour for HTML)
- 404 fallback to custom 404.html
- Security headers (CSP, X-Frame-Options, etc.)
### Docker Compose (Portainer Stack)
```yaml
version: '3.8'
services:
portfolio:
build:
context: .
dockerfile: .docker/Dockerfile
container_name: davidaragon-portfolio
restart: unless-stopped
ports:
- "3010:80"
environment:
- NODE_ENV=production
labels:
- "traefik.enable=true"
- "traefik.http.routers.portfolio.rule=Host(`davidaragon.impresion3d.pro`)"
- "traefik.http.services.portfolio.loadbalancer.server.port=80"
```
### Deployment on 192.168.1.30
1. **Portainer stack deployment**:
- Create new stack "davidaragon-portfolio"
- Upload docker-compose.yml
- Configure environment variables
- Deploy stack
2. **Domain configuration**:
- DNS A record: `davidaragon.impresion3d.pro` → 192.168.1.30
- Traefik reverse proxy for HTTPS
- Let's Encrypt SSL certificate
3. **CI/CD (future)**:
- Gitea Actions on push to main branch
- Build Docker image
- Push to local registry or rebuild on server
- Portainer webhook to redeploy stack
---
## 7. Content Strategy
### Launch Content (5 Blog Posts)
1. **"Building in Public: My €4M ARR Portfolio Plan"**
- Category: Business
- Tags: indie hacker, SaaS portfolio, build-in-public
- Length: 1,500-2,000 words
2. **"Why I'm Building Spanish-First SaaS Products"**
- Category: Business
- Tags: Spanish SaaS, European market, compliance
- Length: 1,200-1,500 words
3. **"WarrantyHub: Building a PWA with 79% AI-Assisted Development"**
- Category: Technical
- Tags: PWA, AI-assisted coding, React, IndexedDB
- Length: 2,000-2,500 words
4. **"Validating Business Ideas: My €4M Research Framework"**
- Category: Business
- Tags: business validation, market research
- Length: 1,800-2,200 words
5. **"From Concept to €502K ARR: WarrantyHub Financial Model"**
- Category: Business
- Tags: SaaS financial model, ARR projections
- Length: 1,500-2,000 words
### Project Case Studies (2)
1. **WarrantyHub**: Problem, solution, architecture, status
2. **Garage61 API**: Platform strategy, Python library, future plans
---
## 8. Pages Architecture
### Homepage (`/`)
- **Hero section**: Personal tagline + CTA
- **Featured projects**: 3-4 project cards
- **Latest blog posts**: 3 most recent posts
- **Newsletter signup**: Email collection form
- **Social links**: X, LinkedIn, GitHub
### About Page (`/about`)
- **Personal story**: 400-600 word narrative
- **Professional photo**: Authentic builder vibe
- **Technical skills**: Tech stack showcase
- **Current focus**: Portfolio goals and timeline
- **Contact**: Email, social media
### Projects Page (`/projects`)
- **Project grid**: All projects with filters (status, tech)
- **Project cards**: Title, description, tech stack, links
- **Featured projects**: Highlighted at top
- **Case study links**: Deep-dive for each project
### Blog Page (`/blog`)
- **Post listing**: All posts with pagination (10 per page)
- **Category filters**: Technical, Business, Personal
- **Tag cloud**: Popular tags
- **Search**: Client-side search with Fuse.js
- **RSS link**: Subscribe to feed
### Individual Post (`/blog/[slug]`)
- **Post metadata**: Publish date, reading time, tags
- **Table of contents**: Auto-generated from headings
- **Syntax highlighting**: Code blocks with copy button
- **Share buttons**: X, LinkedIn
- **Related posts**: 3 similar posts by tags
### Individual Project (`/projects/[slug]`)
- **Project header**: Title, status, links (live, GitHub)
- **Hero image**: Screenshot or diagram
- **Overview**: Problem statement and solution
- **Tech stack**: Technologies used
- **Architecture**: Technical deep-dive
- **Learnings**: Challenges and insights
- **Next steps**: Future roadmap
---
## 9. Performance Optimization
### Build-Time Optimizations
- **Image optimization**: Astro Image component with automatic format conversion (WebP/AVIF)
- **Code splitting**: Per-route JavaScript bundles
- **CSS purging**: Unused Tailwind classes removed
- **HTML minification**: Whitespace removal
- **Asset hashing**: Cache-busting for updated files
### Runtime Optimizations
- **Lazy loading**: Images load on scroll
- **Preload critical fonts**: Inter and JetBrains Mono
- **DNS prefetch**: External resources (analytics, CDN)
- **Service worker** (future): Offline support and caching
### Bundle Size Targets
- **Total JS**: <50KB (gzipped)
- **Total CSS**: <30KB (gzipped)
- **First load**: <200KB total
---
## 10. Analytics & Monitoring
### Analytics
- **Plausible Analytics**: Privacy-friendly, GDPR-compliant
- Deployed on 192.168.1.30 (Docker container)
- Custom events: Newsletter signups, project clicks
- Traffic sources, top pages, referrers
### Monitoring
- **Uptime monitoring**: Portainer health checks
- **Error tracking**: Browser console errors (future: Sentry)
- **Performance monitoring**: Lighthouse CI on builds
---
## 11. Security Considerations
### Headers
- **Content-Security-Policy**: Restrict script sources
- **X-Frame-Options**: Prevent clickjacking
- **X-Content-Type-Options**: Prevent MIME sniffing
- **Referrer-Policy**: Control referrer information
### HTTPS
- **Let's Encrypt certificate**: Auto-renewal via Traefik
- **HSTS**: Force HTTPS after first visit
- **Redirect HTTP → HTTPS**: Automatic
### Input Validation
- **Newsletter form**: Email validation, CSRF protection
- **Contact form** (future): Rate limiting, spam prevention
---
## 12. Acceptance Criteria
### Foundation Complete
- ✅ Astro project initialized with TypeScript
- ✅ Tailwind CSS configured with custom design system
- ✅ Content collections set up for blog and projects
- ✅ All core pages implemented (Home, About, Projects, Blog)
- ✅ Responsive design (mobile, tablet, desktop)
- ✅ Dark mode fully implemented
### Content Complete
- ✅ 5 blog posts written and published
- ✅ 2 project case studies complete
- ✅ Visual assets created (images, diagrams, photos)
- ✅ RSS feed functional
- ✅ Sitemap generated
### Deployment Complete
- ✅ Docker image built (<50MB)
- ✅ Nginx configured with security headers
- ✅ Portainer stack deployed on 192.168.1.30
- ✅ Domain davidaragon.impresion3d.pro resolves correctly
- ✅ HTTPS working with valid certificate
- ✅ Analytics tracking functional
### Performance & SEO
- ✅ Lighthouse score 95+ (all categories)
- ✅ Core Web Vitals pass
- ✅ All meta tags present
- ✅ Open Graph images for all posts
- ✅ Sitemap accessible at /sitemap.xml
- ✅ RSS feed accessible at /rss.xml
---
## 13. Future Enhancements (Post-Launch)
### Phase 2 (Month 2-3)
- Newsletter automation (email drip campaigns)
- Search functionality (Algolia or Fuse.js)
- Comment system (giscus or custom)
- Related posts algorithm improvement
### Phase 3 (Month 4-6)
- Interactive demos for projects (embedded CodeSandbox)
- Video content (embedded YouTube)
- Spanish/English language toggle
- Progressive Web App features (offline support)
### Phase 4 (Month 7+)
- Guest blog posts from community
- Podcast interviews (embedded player)
- Community forum or discussion board
- Advanced analytics dashboard
---
## 14. Maintenance Plan
### Weekly (6-9 hours)
- **Content**: 1 blog post (4-6 hours)
- **Engagement**: Social media, comments (2-3 hours)
### Monthly
- **Review analytics**: Traffic, popular content
- **Update projects**: Status, screenshots
- **Dependency updates**: npm audit, security patches
### Quarterly
- **Content audit**: Update outdated posts
- **SEO review**: Keyword performance, backlinks
- **Performance audit**: Lighthouse scores, Core Web Vitals
---
## 15. Success Metrics
### Launch (Month 1)
- 500+ unique visitors
- 50+ email subscribers
- 20+ social shares
- 5+ inbound inquiries
### Growth (Month 3)
- 2,000+ unique visitors
- 100+ email subscribers
- 5+ qualified leads
### Long-term (Year 1)
- 5,000+ monthly visitors
- 300+ email subscribers
- 50+ customers attributed to content
- €15K+ customer acquisition value saved
---
**Architecture designed by**: wh-leader
**Date**: May 8, 2026
**Status**: Ready for implementation
**Next**: Break into atomic implementer tasks
+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;"]
+300
View File
@@ -0,0 +1,300 @@
# Personal Portfolio Implementation Roadmap
**Project**: davidaragon.impresion3d.pro
**Goal**: Professional portfolio + dev blog for indie builder
**Timeline**: 4 weeks (3 weeks implementation + 1 week content/polish)
**Methodology**: Atomic task decomposition → Parallel implementation → Sequential deployment
---
## Task Dependency Graph
```
Foundation (Week 1)
====================
t_28db7e16: [#29-impl-1] Initialize Astro + Tailwind + TypeScript
└── t_0078b4b3: [#29-impl-2] Content collections (blog + projects schemas)
├── t_0ec36bde: [#29-impl-3] Base layout (Header, Footer, Nav)
│ ├── t_6e85e022: [#29-impl-4] About page ║
│ ├── t_7aa42f76: [#29-impl-5] Homepage (hero + featured) ║
│ ├── t_b7d940a1: [#29-impl-6] Blog pages + RSS ║
│ └── t_26ed02cc: [#29-impl-7] Projects pages ║
└── (All above) ──→ t_66684b8f: [#29-impl-8] Docker deployment
Content & Polish (Week 3-4)
============================
t_c4e23cc8: [#29-impl-9] Blog content (5 posts) ║
t_637198bd: [#29-impl-10] Project case studies (3 projects) ║
t_8f00b91a: [#29-impl-11] SEO optimization (structured data, OG, analytics)
Legend:
║ = Can run in parallel
└── = Must wait for parent
```
---
## Phase Breakdown
### Phase 1: Foundation (Week 1)
**Priority: P1** (all tasks blocking)
| Task ID | Title | Assignee | Est. Time | Blocks |
|---------|-------|----------|-----------|--------|
| t_28db7e16 | Initialize Astro + Tailwind | wh-implementer | 2-3h | ALL |
| t_0078b4b3 | Content collections | wh-implementer | 3-4h | Layouts + Pages |
| t_0ec36bde | Base layout | wh-implementer | 3-4h | All pages |
| t_6e85e022 | About page | wh-implementer | 2-3h | - |
| t_7aa42f76 | Homepage hero | wh-implementer | 3-4h | - |
**Parallelization opportunity**: After #29-impl-3 completes, tasks #29-impl-4, #29-impl-5, #29-impl-6, #29-impl-7 can ALL run simultaneously (4 parallel tracks).
---
### Phase 2: Pages & Features (Week 2)
**Priority: P1-P2**
| Task ID | Title | Assignee | Est. Time | Parallel? |
|---------|-------|----------|-----------|-----------|
| t_b7d940a1 | Blog pages + RSS | wh-implementer | 4-5h | YES ║ |
| t_26ed02cc | Projects pages | wh-implementer | 3-4h | YES ║ |
**Can run in parallel** with #29-impl-4 and #29-impl-5.
---
### Phase 3: Deployment (Week 2-3)
**Priority: P1** (blocks launch)
| Task ID | Title | Assignee | Est. Time | Depends on |
|---------|-------|----------|-----------|------------|
| t_66684b8f | Docker + Nginx + Portainer | wh-implementer | 4-5h | ALL pages (impl-1 through impl-7) |
**Must wait** for all page implementation tasks to complete (can't deploy empty site).
---
### Phase 4: Content Creation (Week 3-4)
**Priority: P2** (nice-to-have for MVP, critical for launch)
| Task ID | Title | Assignee | Est. Time | Parallel? |
|---------|-------|----------|-----------|-----------|
| t_c4e23cc8 | Blog posts (5 articles) | wh-writer | 6-8h | YES ║ |
| t_637198bd | Project case studies (3 projects) | wh-writer | 4-5h | YES ║ |
**Can run in parallel** (different content types, no dependencies).
---
### Phase 5: SEO & Polish (Week 4)
**Priority: P3** (launch enhancement)
| Task ID | Title | Assignee | Est. Time | Depends on |
|---------|-------|----------|-----------|------------|
| t_8f00b91a | SEO optimization | wh-implementer | 2-3h | ALL pages |
---
## Critical Path Analysis
**Longest sequential path** (critical path):
```
impl-1 (3h) → impl-2 (4h) → impl-3 (4h) → impl-8 (5h) = 16 hours
```
**With parallelization**:
```
Week 1: impl-1 (3h) → impl-2 (4h) → impl-3 (4h) = 11h
Week 2: impl-4, impl-5, impl-6, impl-7 in parallel (max 5h)
Week 2: impl-8 (5h)
Week 3-4: impl-9, impl-10 in parallel (max 8h)
Week 4: impl-11 (3h)
Total calendar time: 3-4 weeks
Total sequential hours: 16h (foundation) + 5h (pages) + 5h (deploy) + 8h (content) + 3h (SEO) = 37 hours
With parallelization: ~27 hours of actual work
```
---
## Assignee Profiles
| Profile | Responsibilities | Skills | Tasks |
|---------|------------------|--------|-------|
| **wh-implementer** | Code implementation | Astro, React, Docker, FastAPI | impl-1 through impl-8, impl-11 |
| **wh-writer** | Content creation | Spanish writing, technical writing, SEO | impl-9, impl-10 |
| **wh-leader** | Orchestration | Planning, Gitea issues, monitoring | This task (t_22a3d7db) |
---
## Kanban Board States
All tasks created in **`todo`** state.
Transition rules:
- `todo``ready`: When all parent tasks in `done`
- `ready``running`: Dispatcher assigns to available worker
- `running``done`: Worker calls `kanban_complete(...)`
- `running``blocked`: Worker needs human input
---
## Acceptance Gates
### Gate 1: Foundation Complete (Week 1)
- [ ] Tasks impl-1, impl-2, impl-3 in `done`
- [ ] `npm run dev` starts without errors
- [ ] Tailwind CSS working (test with colored elements)
- [ ] Content collections validated (no schema errors)
- [ ] Header, Footer, Navigation visible
### Gate 2: Pages Complete (Week 2)
- [ ] Tasks impl-4, impl-5, impl-6, impl-7 in `done`
- [ ] All pages accessible (`/`, `/about`, `/blog`, `/projects`)
- [ ] No TypeScript errors
- [ ] No broken links
- [ ] Responsive design works (mobile, tablet, desktop)
### Gate 3: Deployment Ready (Week 2-3)
- [ ] Task impl-8 in `done`
- [ ] Docker image builds successfully
- [ ] Final image size <50MB
- [ ] Health check passes
- [ ] Site accessible at http://localhost:3010
- [ ] Nginx serves static files with gzip compression
### Gate 4: Content Published (Week 3-4)
- [ ] Tasks impl-9, impl-10 in `done`
- [ ] At least 3 blog posts published
- [ ] At least 2 project case studies published
- [ ] All content validates against schemas
- [ ] No typos or grammar errors
### Gate 5: SEO Optimized (Week 4)
- [ ] Task impl-11 in `done`
- [ ] Sitemap generated and accessible
- [ ] robots.txt present
- [ ] Structured data validates (Google Rich Results Test)
- [ ] OG preview looks correct (opengraph.xyz)
- [ ] Analytics configured (if using)
### Gate 6: Production Launch
- [ ] All 11 implementation tasks in `done`
- [ ] All 6 acceptance gates passed
- [ ] Portainer stack deployed to 192.168.1.30:3010
- [ ] HTTPS working via Traefik (davidaragon.impresion3d.pro)
- [ ] DNS pointing to correct server
- [ ] Health check passing
- [ ] Site accessible publicly
---
## Gitea Issue Mapping
All tasks map to **Gitea Issue #29**: "Build personal portfolio with blog and projects showcase"
Task naming convention: `[#29-impl-N] Description`
This allows tracking all work back to the single strategic Gitea issue while maintaining atomic Kanban tasks for execution.
---
## Known Blockers & Mitigations
### Blocker 1: Git Push Authentication
**Status**: BLOCKED
**Impact**: Cannot push commits to GitLab
**Mitigation**:
- Commits created locally (all changes staged)
- Push manually after SSH key configured
- Or: use personal access token with HTTPS
**Resolution needed**: SSH key or GitLab personal access token
### Blocker 2: Real Images (OG, Favicon, Avatar)
**Status**: LOW PRIORITY
**Impact**: Site uses placeholders for images
**Mitigation**:
- Create placeholders in implementation tasks
- Replace with real images before launch
- Can launch with placeholders if needed (not blocking)
**Resolution**: Create professional OG image (1200x630), favicon set, avatar photo
### Blocker 3: Analytics Configuration
**Status**: OPTIONAL
**Impact**: No traffic analytics initially
**Mitigation**:
- Launch without analytics (privacy-first)
- Or: set up self-hosted Plausible later
- Not blocking for MVP
**Resolution**: Optional - configure Plausible after launch
---
## Success Metrics
### Development Metrics
- [ ] 11 tasks completed in 4 weeks
- [ ] Zero P1 bugs at launch
- [ ] <50MB Docker image size
- [ ] >80% test coverage (if tests added)
- [ ] 100% TypeScript strict compliance
### Product Metrics (post-launch)
- Lighthouse Score: >90 (all categories)
- Core Web Vitals: Pass (LCP <2.5s, FID <100ms, CLS <0.1)
- Uptime: >99% (monitor with UptimeRobot)
- Page load: <2s (first contentful paint)
### Content Metrics (Month 1)
- 3-5 blog posts published
- 2-3 project case studies published
- RSS subscribers: goal 50+
- Organic traffic: goal 100+ visitors/month
---
## Post-Launch Roadmap
### Month 1-2: Content Cadence
- Publish 2 blog posts per week
- Update project case studies with metrics
- Add 1-2 more projects (Garage61 API, etc.)
### Month 3-4: Features
- Newsletter signup backend (ConvertKit/Mailchimp)
- Comments system (Giscus/Utterances)
- Search functionality
- Dark/light mode toggle
### Month 5-6: Growth
- Guest posts on Spanish indie blogs
- X (Twitter) thread automation from blog posts
- SEO optimization (internal linking, backlinks)
- Analytics dashboard (public metrics page)
---
## Contact & Support
**Project Owner**: David Aragón (caleidos)
**Primary Agent**: wh-leader (orchestrator)
**Repository**: https://gitlab.impresion3d.pro/root/davidaragon-portfolio
**Deployment**: 192.168.1.30:3010 → davidaragon.impresion3d.pro
**Blockers?** Comment on Kanban task or create Gitea issue.
---
**Generated**: 2026-05-08
**Last Updated**: 2026-05-08
**Status**: ✅ Ready for execution
+231
View File
@@ -0,0 +1,231 @@
# Portfolio Project Status - Orchestration Summary
**Date**: 2026-05-08
**Orchestrator**: wh-leader
**Task**: t_22a3d7db
**Gitea Issue**: #29
---
## ✅ Orchestration Complete
Successfully decomposed personal portfolio project into **11 atomic implementation tasks** with clear dependencies, assignees, and acceptance criteria.
---
## 📋 Tasks Created
| ID | Title | Assignee | Phase | Priority | Status |
|----|-------|----------|-------|----------|--------|
| t_28db7e16 | [#29-impl-1] Initialize Astro + Tailwind + TypeScript | wh-implementer | Foundation | P1 | todo |
| t_0078b4b3 | [#29-impl-2] Content collections (blog + projects) | wh-implementer | Foundation | P1 | todo |
| t_0ec36bde | [#29-impl-3] Base layout (Header, Footer, Nav) | wh-implementer | Foundation | P1 | todo |
| t_6e85e022 | [#29-impl-4] About page | wh-implementer | Foundation | P2 | todo |
| t_7aa42f76 | [#29-impl-5] Homepage (hero + featured) | wh-implementer | Foundation | P1 | todo |
| t_b7d940a1 | [#29-impl-6] Blog pages + RSS feed | wh-implementer | Pages | P2 | todo |
| t_26ed02cc | [#29-impl-7] Projects pages | wh-implementer | Pages | P2 | todo |
| t_66684b8f | [#29-impl-8] Docker deployment | wh-implementer | Deployment | P1 | todo |
| t_c4e23cc8 | [#29-impl-9] Blog content (5 posts) | wh-writer | Content | P2 | todo |
| t_637198bd | [#29-impl-10] Project case studies (3 projects) | wh-writer | Content | P2 | todo |
| t_8f00b91a | [#29-impl-11] SEO optimization | wh-implementer | Polish | P3 | todo |
**Total**: 11 tasks
**Estimated effort**: 37 hours sequential, ~27 hours with parallelization
**Timeline**: 4 weeks
---
## 🔄 Dependency Structure
```
Foundation (Sequential)
=======================
impl-1 → impl-2 → impl-3
Pages (Parallel after impl-3)
==============================
impl-4 ║ impl-5 ║ impl-6 ║ impl-7
Deployment (Sequential, waits for all pages)
============================================
impl-8
Content (Parallel, independent)
================================
impl-9 ║ impl-10
Polish (Sequential, waits for all pages)
=========================================
impl-11
```
**Critical Path**: impl-1 → impl-2 → impl-3 → impl-8 (16 hours)
---
## 📄 Documentation Created
1. **ARCHITECTURE.md** (15.7KB)
- Complete technical architecture
- Tech stack breakdown (Astro, Tailwind, Docker, Nginx)
- Directory structure
- Deployment strategy
- Content types and schemas
- SEO and performance requirements
2. **IMPLEMENTATION-ROADMAP.md** (9.4KB)
- Task dependency graph
- Phase breakdown with timing
- Critical path analysis
- Assignee profiles
- Acceptance gates (6 gates)
- Known blockers and mitigations
- Success metrics
- Post-launch roadmap
---
## 🎯 Key Decisions
### Tech Stack
- **Framework**: Astro (static site generation, fast)
- **Styling**: Tailwind CSS (design system, dark mode)
- **Language**: TypeScript strict mode (type safety)
- **Content**: Astro Content Collections (type-safe markdown)
- **Deployment**: Docker + Nginx (production-ready)
- **Orchestration**: Portainer (container management)
- **Proxy**: Traefik (automatic HTTPS)
### Architecture Patterns
- **Spanish-first**: All UI text in Spanish (es-ES locale)
- **Dark mode only**: No light mode toggle (simplicity)
- **Static generation**: All pages pre-rendered (performance)
- **Content types**: Blog posts + project case studies
- **SEO-first**: Sitemap, structured data, OG tags
- **Privacy-focused**: No cookies, optional analytics (Plausible)
### Deployment Strategy
- **Host**: 192.168.1.30:3010
- **Domain**: davidaragon.impresion3d.pro
- **HTTPS**: Via Traefik with Let's Encrypt
- **Image**: Multi-stage Docker build (<50MB)
- **Server**: Nginx with gzip, caching, security headers
- **Health checks**: Built-in for monitoring
---
## ⚠️ Known Blockers
### 1. Git Push Authentication
**Status**: BLOCKED
**Impact**: Cannot push commits to GitLab (2 commits staged locally)
**Mitigation**: Commits created locally, will push when SSH key configured
**Action needed**: Configure SSH key or personal access token
### 2. Real Images
**Status**: OPTIONAL
**Impact**: Placeholders for OG image, favicon, avatar
**Mitigation**: Launch with placeholders, replace later
**Action needed**: Create 1200x630 OG image, favicon set, avatar photo
---
## 📊 Success Criteria
### Development (Gates 1-3)
- [ ] All foundation tasks complete (impl-1, impl-2, impl-3)
- [ ] All page tasks complete (impl-4 through impl-7)
- [ ] Docker deployment working (impl-8)
- [ ] Site accessible locally at http://localhost:3010
- [ ] No TypeScript errors, no broken links
### Content (Gate 4)
- [ ] 3-5 blog posts published (impl-9)
- [ ] 2-3 project case studies (impl-10)
- [ ] All content validates against schemas
### SEO (Gate 5)
- [ ] Structured data validates (Google Rich Results Test)
- [ ] OG preview looks correct (opengraph.xyz)
- [ ] Sitemap generated and accessible
### Launch (Gate 6)
- [ ] HTTPS working via Traefik
- [ ] DNS pointing to davidaragon.impresion3d.pro
- [ ] Health check passing
- [ ] Uptime monitoring configured
---
## 🚀 Next Steps
### Immediate (Dispatcher)
1. Dispatcher picks up tasks in `todo` state
2. Promotes impl-1 to `ready` (no dependencies)
3. Assigns to wh-implementer profile
4. Worker executes impl-1 → impl-2 → impl-3 sequentially
### Week 1 (Foundation)
- wh-implementer completes impl-1, impl-2, impl-3
- Gate 1 acceptance: Foundation complete
### Week 2 (Pages - Parallel)
- wh-implementer can work on impl-4, impl-5, impl-6, impl-7 in parallel
- All 4 tasks can be dispatched simultaneously
- Gate 2 acceptance: Pages complete
### Week 2-3 (Deployment)
- wh-implementer completes impl-8
- Test locally before Portainer deployment
- Gate 3 acceptance: Deployment ready
### Week 3-4 (Content)
- wh-writer completes impl-9 and impl-10 in parallel
- Gate 4 acceptance: Content published
### Week 4 (Polish + Launch)
- wh-implementer completes impl-11 (SEO)
- Gate 5 acceptance: SEO optimized
- Manual Portainer deployment to 192.168.1.30
- Gate 6 acceptance: Production launch
---
## 📝 Lessons & Patterns
### Atomic Task Design
- Each task touches 1-3 files maximum
- Clear acceptance criteria (checkboxes)
- One implementer can complete without coordination
- Verifiable in single session (<5 hours)
### Dependency Management
- Sequential where necessary (foundation)
- Parallel where possible (pages, content)
- Clear parent-child relationships in Kanban
### Documentation First
- ARCHITECTURE.md before any code
- IMPLEMENTATION-ROADMAP.md before any tasks
- Both docs committed to repo (source of truth)
### Assignee Specialization
- wh-implementer: Code, infra, deployment (9 tasks)
- wh-writer: Content, Spanish writing (2 tasks)
- wh-leader: Orchestration, planning (1 task)
---
## 🔗 References
- **Repository**: https://gitlab.impresion3d.pro/root/davidaragon-portfolio
- **Gitea Issue**: #29 (Build personal portfolio)
- **Architecture**: /home/caleidos/code_ubuntu/davidaragon-portfolio/ARCHITECTURE.md
- **Roadmap**: /home/caleidos/code_ubuntu/davidaragon-portfolio/IMPLEMENTATION-ROADMAP.md
- **Strategic Plan**: /home/caleidos/code_ubuntu/planificacion_empresarial/ideas/019-personal-portfolio-blog/
---
**Orchestrator**: wh-leader
**Completed**: 2026-05-08
**Status**: ✅ Ready for dispatcher to assign workers
+170 -2
View File
@@ -1,3 +1,171 @@
# davidaragon-portfolio
# David Aragón — Portfolio
Personal tech portfolio and dev blog - davidaragon.impresion3d.pro
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.
- **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
```
## Despliegue (producción)
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.
```bash
# Construir y servir localmente con Docker Compose
docker compose up --build
# -> http://localhost:8080
# Producción: pull de la imagen publicada por CI
docker compose -f docker-compose.prod.yml up -d
# -> http://localhost:3001
```
La imagen final (`nginx:alpine`) solo contiene los estáticos generados en `./dist/`. Ver `Dockerfile` para detalles del multi-stage.
## Cómo añadir contenido
### Nuevo post de blog
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`:
```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
---
# Contenido del post en Markdown
```
Aparecerá automáticamente en `/blog/<slug>` y, si no es draft, en la home.
### 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)
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://davidaragon.impresion3d.pro',
integrations: [
tailwind(),
sitemap(),
],
markdown: {
shikiConfig: {
theme: 'dark-plus',
},
},
});
+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;
}
}
+6841
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "davidaragon-portfolio",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=18.14.1"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^5.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/tailwind": "^6.0.2",
"tailwindcss": "^3.4.17",
"@tailwindcss/typography": "^0.5.15"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

+1
View File
@@ -0,0 +1 @@
Placeholder for favicon - use favicon.io or other generator to create a comprehensive favicon set.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

+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>
+191
View File
@@ -0,0 +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>
<!-- Latest Blog Posts -->
<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">Latest Posts</h2>
<a
href="/blog"
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">
{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>
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-background text-text-primary font-sans;
}
}
+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`;
}
+29
View File
@@ -0,0 +1,29 @@
module.exports = {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
background: '#0a0e27',
surface: '#131729',
primary: '#60a5fa',
secondary: '#a78bfa',
text: {
primary: '#f1f5f9',
secondary: '#94a3b8',
tertiary: '#64748b',
},
accent: {
green: '#34d399',
red: '#f87171',
yellow: '#fbbf24',
}
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
},
plugins: [require('@tailwindcss/typography')],
};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}