Initial commit — docker stack carloselugo.com

Services: Caddy, frontend, Uptime Kuma, Netdata, ntfy, Dozzle, Gitea
This commit is contained in:
2026-04-23 18:57:20 +00:00
commit f510bec447
19 changed files with 1238 additions and 0 deletions
+277
View File
@@ -0,0 +1,277 @@
import { posts } from './post.js'
// ── Helpers ─────────────────────────────────────────────────────────────
const categoryColor = {
homelab: '#c8ff00', // accent lime
personal: '#ff6b35', // orange
professional: '#5b9cf6', // blue
entertainment: '#b47cff', // purple
}
function formatDate(iso) {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
})
}
// ── Post card (list view) ────────────────────────────────────────────────
function renderPostCard(post, index) {
const color = categoryColor[post.category] ?? '#c8ff00'
return `
<article class="blog-card reveal" data-post-id="${post.id}" style="transition-delay:${index * 0.08}s">
<div class="blog-card-meta">
<span class="blog-cat mono" style="color:${color}">● ${post.category}</span>
<span class="blog-date mono">${formatDate(post.date)}</span>
</div>
<h3 class="blog-card-title">${post.title}</h3>
<p class="blog-card-excerpt">${post.excerpt}</p>
<button class="blog-read-btn mono" data-post-id="${post.id}">read →</button>
</article>
`
}
// ── Full post (detail view) ──────────────────────────────────────────────
function renderPostDetail(post) {
const color = categoryColor[post.category] ?? '#c8ff00'
return `
<div class="blog-detail" id="blog-detail">
<button class="blog-back mono" id="blog-back">← back to posts</button>
<div class="blog-detail-meta">
<span class="blog-cat mono" style="color:${color}">● ${post.category}</span>
<span class="blog-date mono">${formatDate(post.date)}</span>
</div>
<h2 class="blog-detail-title">${post.title}</h2>
<div class="blog-detail-body">
${post.content}
</div>
</div>
`
}
// ── Section shell ────────────────────────────────────────────────────────
export function renderBlog() {
// Sort newest first
const sorted = [...posts].sort((a, b) => new Date(b.date) - new Date(a.date))
return `
<section class="blog" id="blog">
<div class="container">
<div class="section-head reveal">
<div class="accent-line"></div>
<h2 class="section-title">Blog</h2>
<p class="blog-intro">
Notes on homelab builds, things I'm working on, and whatever I'm into.
</p>
</div>
<!-- Category filter -->
<div class="blog-filters reveal">
<button class="filter-btn mono active" data-filter="all">all</button>
<button class="filter-btn mono" data-filter="homelab">homelab</button>
<button class="filter-btn mono" data-filter="personal">personal</button>
<button class="filter-btn mono" data-filter="professional">professional</button>
<button class="filter-btn mono" data-filter="entertainment">entertainment</button>
</div>
<!-- Post list -->
<div class="blog-list" id="blog-list">
${sorted.map((p, i) => renderPostCard(p, i)).join('')}
</div>
<!-- Post detail (hidden by default) -->
<div class="blog-detail-wrapper" id="blog-detail-wrapper" style="display:none"></div>
</div>
</section>
<style>
/* ── Section layout ── */
.blog { padding: 100px 0; border-top: 1px solid var(--border); }
.blog-intro {
font-size: 0.95rem;
color: var(--muted);
margin-top: 8px;
max-width: 480px;
}
/* ── Filters ── */
.blog-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 32px 0 40px;
}
.filter-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font-size: 0.7rem;
padding: 4px 14px;
border-radius: 2px;
cursor: pointer;
letter-spacing: 0.05em;
transition: all 0.18s;
}
.filter-btn:hover,
.filter-btn.active {
border-color: var(--accent);
color: var(--accent);
}
/* ── Post list ── */
.blog-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
}
/* ── Card ── */
.blog-card {
background: var(--bg-card);
padding: 28px 32px;
display: flex;
flex-direction: column;
gap: 10px;
transition: background 0.18s;
cursor: default;
}
.blog-card:hover { background: #161616; }
.blog-card[style*="display: none"] { display: none !important; }
.blog-card-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.blog-cat { font-size: 0.68rem; letter-spacing: 0.06em; }
.blog-date { font-size: 0.68rem; color: var(--muted); }
.blog-card-title {
font-size: 1.15rem;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.25;
}
.blog-card-excerpt {
font-size: 0.88rem;
color: var(--muted);
line-height: 1.65;
flex: 1;
}
.blog-read-btn {
background: transparent;
border: none;
color: var(--accent);
font-size: 0.78rem;
padding: 0;
cursor: pointer;
letter-spacing: 0.04em;
align-self: flex-start;
transition: letter-spacing 0.2s;
}
.blog-read-btn:hover { letter-spacing: 0.1em; }
/* ── Detail view ── */
.blog-detail {
max-width: 720px;
padding: 0 0 48px;
animation: fadeUp 0.4s ease both;
}
.blog-back {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font-size: 0.75rem;
padding: 6px 16px;
cursor: pointer;
border-radius: 2px;
margin-bottom: 32px;
letter-spacing: 0.04em;
transition: all 0.18s;
}
.blog-back:hover { border-color: var(--accent); color: var(--accent); }
.blog-detail-meta {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.blog-detail-title {
font-size: clamp(1.8rem, 4vw, 2.8rem);
letter-spacing: -0.03em;
line-height: 1.1;
margin-bottom: 40px;
}
/* Post body typography */
.blog-detail-body { font-size: 1rem; line-height: 1.8; color: var(--text); }
.blog-detail-body p { margin-bottom: 20px; }
.blog-detail-body h4 {
font-size: 1rem;
font-weight: 700;
color: var(--accent);
font-family: var(--font-mono);
letter-spacing: 0.04em;
margin: 28px 0 10px;
}
.blog-detail-body ul {
padding-left: 20px;
margin-bottom: 20px;
color: var(--muted);
}
.blog-detail-body li { margin-bottom: 6px; }
.blog-detail-body code {
background: #1a1a1a;
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 2px;
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--accent);
}
.blog-detail-body strong { color: var(--text); font-weight: 700; }
</style>
`
}
// ── Wire up interactivity (call after DOM insertion) ─────────────────────
export function initBlog() {
const listEl = document.getElementById('blog-list')
const detailEl = document.getElementById('blog-detail-wrapper')
// ── Filter buttons ──
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'))
btn.classList.add('active')
const filter = btn.dataset.filter
document.querySelectorAll('.blog-card').forEach(card => {
const match = filter === 'all' || card.dataset.postId &&
posts.find(p => p.id === card.dataset.postId)?.category === filter
card.style.display = match ? '' : 'none'
})
})
})
// ── Open post ──
document.querySelectorAll('.blog-read-btn').forEach(btn => {
btn.addEventListener('click', () => {
const post = posts.find(p => p.id === btn.dataset.postId)
if (!post) return
listEl.style.display = 'none'
document.querySelector('.blog-filters').style.display = 'none'
detailEl.style.display = 'block'
detailEl.innerHTML = renderPostDetail(post)
detailEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Back button
document.getElementById('blog-back').addEventListener('click', () => {
detailEl.style.display = 'none'
listEl.style.display = ''
document.querySelector('.blog-filters').style.display = ''
})
})
})
}
+24
View File
@@ -0,0 +1,24 @@
export function renderFooter() {
const year = new Date().getFullYear()
return `
<footer class="footer">
<div class="container footer-inner">
<span class="mono footer-copy">© ${year} Carlos Lugo</span>
<span class="mono footer-copy" style="color:var(--muted)">carloselugo.com</span>
</div>
</footer>
<style>
.footer {
border-top: 1px solid var(--border);
padding: 24px 0;
}
.footer-inner {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-copy { font-size: 0.75rem; color: var(--muted); letter-spacing: 0.04em; }
</style>
`
}
+60
View File
@@ -0,0 +1,60 @@
export function renderHeader() {
return `
<header class="site-header">
<div class="container header-inner">
<a href="/" class="logo">
<span class="logo-bracket mono">[</span>
<span class="logo-name">clugo</span>
<span class="logo-bracket mono">]</span>
</a>
<nav class="nav">
<a href="#projects" class="nav-link">projects</a>
<a href="#blog" class="nav-link">blog</a>
<a href="#about" class="nav-link">about</a>
<a href="https://github.com" target="_blank" rel="noopener" class="nav-link nav-link--ext">
github ↗
</a>
</nav>
</div>
</header>
<style>
.site-header {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--border);
background: rgba(10, 10, 10, 0.85);
backdrop-filter: blur(12px);
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
text-decoration: none;
font-family: var(--font-sans);
font-weight: 800;
font-size: 1.1rem;
letter-spacing: -0.02em;
color: var(--text);
transition: color 0.2s;
}
.logo:hover .logo-name { color: var(--accent); }
.logo-bracket { color: var(--accent); }
.logo-name { color: var(--text); }
.nav { display: flex; gap: 28px; }
.nav-link {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.03em;
transition: color 0.2s;
}
.nav-link:hover { color: var(--accent); }
</style>
`
}
+113
View File
@@ -0,0 +1,113 @@
export function renderHero() {
return `
<section class="hero" id="about">
<div class="container hero-inner">
<div class="hero-label mono">// hello world</div>
<h1 class="hero-title">
Carlos<br/>
<span class="hero-title--accent">Lugo.</span>
</h1>
<p class="hero-sub">
Homelab tinkerer. Network nerd.<br/>
Building things that run 24/7 in a rack at home.
</p>
<div class="hero-stack">
<span class="tag">docker</span>
<span class="tag">linux</span>
<span class="tag">networking</span>
<span class="tag">fortigate</span>
<span class="tag">game servers</span>
</div>
<div class="hero-gfx" aria-hidden="true">
<div class="gfx-ring gfx-ring--1"></div>
<div class="gfx-ring gfx-ring--2"></div>
<div class="gfx-dot"></div>
</div>
</div>
</section>
<style>
.hero {
min-height: 88vh;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
padding: 80px 0;
}
.hero::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 1px;
background: var(--border);
}
.hero-inner { position: relative; z-index: 1; }
.hero-label {
color: var(--accent);
font-size: 0.8rem;
letter-spacing: 0.1em;
margin-bottom: 24px;
animation: fadeUp 0.8s ease both;
}
.hero-title {
font-size: clamp(3.5rem, 10vw, 8rem);
line-height: 0.95;
letter-spacing: -0.04em;
margin-bottom: 32px;
animation: fadeUp 0.8s 0.1s ease both;
}
.hero-title--accent { color: var(--accent); }
.hero-sub {
font-size: 1.1rem;
color: var(--muted);
max-width: 380px;
line-height: 1.7;
margin-bottom: 32px;
animation: fadeUp 0.8s 0.2s ease both;
}
.hero-stack {
display: flex;
flex-wrap: wrap;
gap: 8px;
animation: fadeUp 0.8s 0.3s ease both;
}
.hero-gfx {
position: absolute;
right: -80px;
top: 50%;
transform: translateY(-50%);
width: 500px;
height: 500px;
pointer-events: none;
}
.gfx-ring {
position: absolute;
border-radius: 50%;
border: 1px solid var(--border);
top: 50%; left: 50%;
transform: translate(-50%, -50%);
}
.gfx-ring--1 { width: 300px; height: 300px; }
.gfx-ring--2 { width: 480px; height: 480px; border-color: #151515; }
.gfx-dot {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 20px var(--accent);
animation: pulse 2s ease-in-out infinite;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: none; }
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 20px var(--accent); }
50% { box-shadow: 0 0 40px var(--accent), 0 0 80px var(--accent); }
}
</style>
`
}
+86
View File
@@ -0,0 +1,86 @@
// ─────────────────────────────────────────────
// BLOG POSTS — edit here to add / update posts
// Fields:
// id : unique slug, used in URL hash (e.g. 'my-first-post')
// date : ISO string (e.g. '2026-04-14')
// category : 'personal' | 'professional' | 'entertainment' | 'homelab'
// title : string
// excerpt : short summary shown on card
// content : full post body (HTML supported)
// ─────────────────────────────────────────────
export const posts = [
{
id: 'rescued-optiplex-rabbit-hole',
date: '2026-04-10',
category: 'homelab',
title: "Rescued a Dell Optiplex and Now I Can't Stop",
excerpt: "A decommissioned Optiplex 3000, Docker, and a rabbit hole I didn't expect.",
content: `
<p>It started with a Dell Optiplex 3000 that was about to get wiped and trashed. I grabbed it
before it disappeared and figured I'd run a few things on it. That was a mistake — in the best way.</p>
<p>Docker opened a door I didn't know was there. The more I poked around, the more I realized
how much is already solved. Need a game server? There's a container for that. Need to monitor
your uptime, metrics, logs, and analytics? Container, container, container, container.</p>
<p>That's exactly what I ended up with: <strong>Uptime Kuma</strong> for service uptime,
<strong>Netdata</strong> for system metrics, <strong>Umami</strong> for page analytics, and
<strong>Dozzle</strong> for container logs. Simple stack, everything in one place, surprisingly complete.</p>
<p>The part that got me was publishing this site. I was genuinely a little nervous — I was exposing
something to the internet, inside my own network. Old me would've stopped there. Instead I thought:
<em>there's probably a container for the security piece too.</em> Turns out, yeah. Problem solved.</p>
<p>AI has a lot to do with this shift. Things that used to feel out of reach — self-hosting,
networking, monitoring — feel a lot more approachable when you can just ask questions and get unstuck fast.</p>
<p>Next goal: more Optiplexes, a Kubernetes cluster, and seeing how far this rabbit hole actually goes.
I'll keep you posted.</p>
`,
},
{
id: 'survival-sandbox-journey',
date: '2026-04-14',
category: 'entertainment',
title: "Survival Sandboxes and the Itch I Can't Scratch",
excerpt: "From Star Wars Galaxies to Enshrouded — apparently I've always been about building stuff and calling it home.",
content: `
<p>I never connected the dots until recently, but there's a clear thread running through every game
I've actually sunk time into: you build something, and it feels like yours.</p>
<p>It started with <strong>Star Wars Galaxies</strong> — one of the first MMOs I played. Yeah there
was grinding, leveling, the full RPG loop. But what I kept coming back to was base building. There was
something about someone walking into your place and saying <em>"this is cool"</em> that hit different.
That little moment of validation from a stranger in a game. I get it now more than I did then.</p>
<p>Fast forward and I'm playing <strong>Minecraft</strong> with my kid. Graphics are what they are,
but the crafting system is genuinely deep. And watching a kid figure out resource chains and basic
survival logic — that's actually pretty solid for a young brain. It holds up.</p>
<p>A coworker told me he basically lived on Steam sales, buying whatever looked interesting at a
discount. I did the same and landed on <strong>No Man's Sky</strong>. That game broke my brain a
little. Procedurally generated solar systems, planets, flora, fauna — 18 quintillion planets. The
number is absurd. But strip that away and it's basically Minecraft in space: gather everything or
die trying. I put a lot of hours in, but a game that massive is hard to maintain intensity on.
Eventually I dialed back.</p>
<p>Steam does this thing where it watches what you play and suggests similar titles. That's how I
found <strong>Enshrouded</strong>. Fantasy RPG setting, traditional classes, fluid real-time combat
— no turn-based stuff, not my thing — with that same loop of crafting, foraging, and figuring things
out. The quest line actually guides you through different biomes in a way that feels structured without
being hand-holdy. Only complaint: I wish it was on Nintendo Switch so I could play it in traffic.
Half joking.</p>
<p>A coworker mentioned he ran a Minecraft server and something clicked — <em>I wonder if there's a
container for Enshrouded.</em> There was. Threw it on the Dell Optiplex that was headed to
decommission and it runs without breaking a sweat. A simple Docker Compose file and done. Sometimes
working in networking and infrastructure has its perks: you see how the sausage is made and suddenly
self-hosting a game server feels less like a project and more like a Tuesday.</p>
<p>How long will the Enshrouded phase last? No idea. But here's the takeaway: if something looks
interesting, Google it. There's probably a server image, a mod, a community, or at minimum a Reddit
thread that tells you if it's worth your time.</p>
`,
},
]
+108
View File
@@ -0,0 +1,108 @@
const projects = [
{
id: '01',
title: 'Enshrouded Server',
desc: 'Dedicated game server running on bare metal. Docker Compose, Watchtower for auto-updates, daily backups.',
tags: ['docker', 'ubuntu', 'game server'],
status: 'live',
},
{
id: '02',
title: 'Homelab Network',
desc: 'Fortigate firewall at the edge, VLANs, static IP. Built to host services publicly with proper segmentation.',
tags: ['fortigate', 'networking', 'vlan'],
status: 'wip',
},
{
id: '03',
title: 'This Site',
desc: 'Vite + VanillaJS served behind Caddy with automatic SSL. Zero frameworks, full control.',
tags: ['vite', 'caddy', 'docker'],
status: 'live',
},
]
const statusLabel = {
live: { text: 'live', color: '#c8ff00' },
wip: { text: 'wip', color: '#ff6b35' },
}
export function renderProjects() {
const cards = projects.map(p => {
const s = statusLabel[p.status]
return `
<article class="card reveal">
<div class="card-top">
<span class="card-id mono">${p.id}</span>
<span class="card-status mono" style="color:${s.color}">● ${s.text}</span>
</div>
<h3 class="card-title">${p.title}</h3>
<p class="card-desc">${p.desc}</p>
<div class="card-tags">
${p.tags.map(t => `<span class="tag">${t}</span>`).join('')}
</div>
</article>
`
}).join('')
return `
<section class="projects" id="projects">
<div class="container">
<div class="section-head reveal">
<div class="accent-line"></div>
<h2 class="section-title">Projects</h2>
</div>
<div class="card-grid">
${cards}
</div>
</div>
</section>
<style>
.projects { padding: 100px 0; }
.section-head { margin-bottom: 48px; }
.section-title {
font-size: clamp(2rem, 5vw, 3.5rem);
letter-spacing: -0.03em;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
}
.card {
background: var(--bg-card);
padding: 32px;
transition: background 0.2s;
cursor: default;
}
.card:hover { background: #161616; }
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-id { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.1em; }
.card-status { font-size: 0.7rem; letter-spacing: 0.05em; }
.card-title {
font-size: 1.3rem;
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 12px;
}
.card-desc {
font-size: 0.9rem;
color: var(--muted);
line-height: 1.65;
margin-bottom: 20px;
}
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; }
/* Staggered reveal for cards */
.card-grid .card:nth-child(2) { transition-delay: 0.1s; }
.card-grid .card:nth-child(3) { transition-delay: 0.2s; }
</style>
`
}
+35
View File
@@ -0,0 +1,35 @@
import './style.css'
import { renderHeader } from './components/header.js'
import { renderHero } from './components/hero.js'
import { renderProjects } from './components/projects.js'
import { renderBlog, initBlog } from './components/blog.js'
import { renderFooter } from './components/footer.js'
const app = document.getElementById('app')
app.innerHTML = `
${renderHeader()}
<main>
${renderHero()}
${renderProjects()}
${renderBlog()}
</main>
${renderFooter()}
`
// Scroll reveals
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible')
observer.unobserve(entry.target)
}
})
},
{ threshold: 0.1 }
)
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el))
// Blog interactivity
initBlog()
+83
View File
@@ -0,0 +1,83 @@
/* ── Design tokens ── */
:root {
--bg: #0a0a0a;
--bg-card: #111111;
--border: #1e1e1e;
--accent: #c8ff00; /* electric lime */
--accent-2: #ff6b35; /* orange pop */
--text: #e8e8e8;
--muted: #555;
--font-mono: 'Space Mono', monospace;
--font-sans: 'Syne', sans-serif;
--radius: 4px;
--max-w: 1100px;
}
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* ── Noise overlay ── */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
opacity: 0.4;
}
/* ── Layout ── */
.container {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 24px;
}
/* ── Typography ── */
h1, h2, h3 { font-family: var(--font-sans); font-weight: 800; line-height: 1.1; }
code, .mono { font-family: var(--font-mono); font-size: 0.85em; }
/* ── Scroll reveal ── */
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.visible {
opacity: 1;
transform: none;
}
/* ── Accent line ── */
.accent-line {
display: inline-block;
width: 40px;
height: 3px;
background: var(--accent);
margin-bottom: 16px;
}
/* ── Tag pill ── */
.tag {
display: inline-block;
font-family: var(--font-mono);
font-size: 0.7rem;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--muted);
letter-spacing: 0.05em;
}