// One portal to rule them all.
// WHAT WE'RE BUILDING
In this project, you'll create a beautiful dashboard that serves as the gateway to all your self-hosted services. Instead of remembering dozens of URLs, you'll have one beautiful landing page linking to everything: your blog, pastebin, mail, VPN, and more.
// WHY THIS MATTERS
As you add more self-hosted services, keeping track of them becomes a nightmare. A homelab dashboard solves this by providing a unified, beautiful interface. It also lets you show off your homelab to friends and family with a professional-looking portal.
This project combines design and functionality:
Prerequisites
Before starting, make sure you've completed:
Your dashboard will be a simple PHP application:
Let's create our project directory and set up the foundation.
$ mkdir -p ~/projects/homelab-dashboard # Create project folder $ cd ~/projects/homelab-dashboard
$ git init $ git config user.name "Your Name" $ git config user.email "your@email.com"
$ mkdir -p css js assets/icons
Now let's build the dashboard components.
This file defines all your services:
$ nano services.json
{
"services": [
{
"name": "Blog",
"description": "My personal blog",
"url": "https://blog.example.com",
"icon": "blog",
"color": "#E74C3C"
},
{
"name": "Pastebin",
"description": "Encrypted pastebin",
"url": "https://paste.example.com",
"icon": "lock",
"color": "#2ECC71"
},
{
"name": "Mail",
"description": "Webmail access",
"url": "https://mail.example.com",
"icon": "mail",
"color": "#E74C3C"
},
{
"name": "VPN",
"description": "WireGuard VPN",
"url": "https://vpn.example.com",
"icon": "shield",
"color": "#9B59B6"
},
{
"name": "Files",
"description": "File sharing",
"url": "https://files.example.com",
"icon": "folder",
"color": "#F39C12"
},
{
"name": "Monitoring",
"description": "Server stats",
"url": "https://monitor.example.com",
"icon": "chart",
"color": "#3498DB"
}
]
}
The main dashboard page:
$ nano index.php
<?php
$servicesJson = file_get_contents('services.json');
$services = json_decode($servicesJson, true)['services'];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Homelab Dashboard</title>
<link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="container">
<header>
<h1>Homelab</h1>
<p class="tagline">// All your services in one place</p>
<div class="search-box">
<input type="text" id="search" placeholder="Search services...">
</div>
</header>
<main class="services-grid">
<?php foreach ($services as $service): ?>
<div class="service-card" data-name="<?= strtolower($service['name']) ?>">
<a href="<?= $service['url'] ?>" target="_blank" rel="noopener">
<div class="service-icon" style="background-color: <?= $service['color'] ?>">
<svg viewBox="0 0 24 24" fill="currentColor">
<?= getIcon($service['icon']) ?>
</svg>
</div>
<div class="service-info">
<h3><?= $service['name'] ?></h3>
<p><?= $service['description'] ?></p>
</div>
<div class="status-indicator online" title="Online"></div>
</a>
</div>
<?php endforeach; ?>
</main>
<footer>
<p>Powered by your own infrastructure. <a href="https://rebelwithlinux.com">Rebel With Linux</a></p>
</footer>
</div>
<script src="js/main.js"></script>
</body>
</html>
<?php
function getIcon($name) {
$icons = [
'blog' => '<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>',
'lock' => '<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>',
'mail' => '<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>',
'shield' => '<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>',
'folder' => '<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>',
'chart' => '<path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z"/>'
];
return isset($icons[$name]) ? $icons[$name] : '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>';
}
?>
Beautiful styling:
$ nano css/style.css
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-card: #21262d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--accent: #58a6ff;
--border: #30363d;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'IBM Plex Sans', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
background-image:
radial-gradient(ellipse at top, #1a2332 0%, transparent 50%),
radial-gradient(ellipse at bottom right, #0d2847 0%, transparent 50%);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
}
header {
text-align: center;
margin-bottom: 60px;
}
header h1 {
font-family: 'IBM Plex Mono', monospace;
font-size: 4rem;
font-weight: 700;
letter-spacing: -2px;
margin-bottom: 10px;
background: linear-gradient(135deg, #58a6ff, #a371f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.tagline {
color: var(--text-secondary);
font-family: 'IBM Plex Mono', monospace;
font-size: 1.1rem;
margin-bottom: 30px;
}
.search-box input {
width: 100%;
max-width: 400px;
padding: 15px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 1rem;
font-family: inherit;
transition: border-color 0.3s, box-shadow 0.3s;
}
.search-box input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 60px;
}
.service-card a {
display: flex;
align-items: center;
gap: 15px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
text-decoration: none;
color: var(--text-primary);
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
position: relative;
}
.service-card a:hover {
transform: translateY(-2px);
border-color: var(--accent);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
.service-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.service-icon svg {
width: 24px;
height: 24px;
color: white;
}
.service-info {
flex: 1;
min-width: 0;
}
.service-info h3 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 4px;
}
.service-info p {
color: var(--text-secondary);
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-indicator.online {
background: #2ea043;
box-shadow: 0 0 8px rgba(46, 160, 67, 0.5);
}
.status-indicator.offline {
background: #da3633;
}
.status-indicator.checking {
background: #f0883e;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
footer {
text-align: center;
color: var(--text-secondary);
font-size: 0.875rem;
}
footer a {
color: var(--accent);
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
header h1 {
font-size: 2.5rem;
}
.services-grid {
grid-template-columns: 1fr;
}
}
Search and status checking:
$ nano js/main.js
document.addEventListener('DOMContentLoaded', () => {
const searchInput = document.getElementById('search');
const serviceCards = document.querySelectorAll('.service-card');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
serviceCards.forEach(card => {
const name = card.dataset.name;
if (name.includes(query)) {
card.style.display = 'block';
} else {
card.style.display = 'none';
}
});
});
checkServiceStatus();
});
async function checkServiceStatus() {
const services = document.querySelectorAll('.service-card a');
services.forEach(async (service) => {
const indicator = service.querySelector('.status-indicator');
const url = service.href;
indicator.classList.remove('online', 'offline');
indicator.classList.add('checking');
try {
const response = await fetch(url, {
method: 'HEAD',
mode: 'no-cors',
cache: 'no-cache'
});
indicator.classList.remove('checking');
indicator.classList.add('online');
} catch (error) {
indicator.classList.remove('checking');
indicator.classList.add('offline');
});
}
setInterval(checkServiceStatus, 60000);
Containerize your dashboard for easy deployment.
$ nano Dockerfile
FROM php:8.2-apache
RUN apt-get update && apt-get install -y \
git \
&& rm -rf /var/lib/apt/lists/*
COPY . /var/www/html/
RUN chmod -R 755 /var/www/html/ \
&& chmod -R 777 /var/www/html/css \
&& chmod -R 777 /var/www/html/js
EXPOSE 80
CMD ["apache2-foreground"]
$ nano docker-compose.yml
version: '3.8'
services:
homelab:
build: .
container_name: homelab-dashboard
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./services.json:/var/www/html/services.json:ro
environment:
- TZ=UTC
labels:
- "com.crewbuild.homelab.description=Homelab Dashboard"
$ docker-compose up -d --build # Build and start the container
$ docker-compose logs -f # View logs
Get your dashboard online.
$ git add . $ git commit -m "Initial homelab dashboard" $ git remote add origin git@github.com:yourusername/homelab-dashboard.git $ git push -u origin main
$ ssh user@your-server $ mkdir -p ~/homelab && cd ~/homelab $ git clone git@github.com:yourusername/homelab-dashboard.git .
$ docker-compose up -d --build
$ sudo nano /etc/nginx/sites-available/homelab
server {
listen 80;
server_name dashboard.example.com;
location / {
proxy_pass http://127.0.0.1:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
$ sudo ln -s /etc/nginx/sites-available/homelab /etc/nginx/sites-enabled/ $ sudo nginx -t $ sudo systemctl reload nginx
$ sudo certbot --nginx -d dashboard.example.com
Your dashboard is just the beginning. Here's how to expand it:
Alternative Solutions
If you'd prefer ready-made solutions, check out: