OVERVIEW SETUP CODE DOCKER VPS

BUILD A HOMELAB DASHBOARD

// 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.

// What You'll Learn

This project combines design and functionality:

Features We'll Build

Prerequisites

Before starting, make sure you've completed:

The Architecture

Your dashboard will be a simple PHP application:

// Part 1: Set Up Local Development

Let's create our project directory and set up the foundation.

1.1 Create Project Directory

$ mkdir -p ~/projects/homelab-dashboard
# Create project folder
$ cd ~/projects/homelab-dashboard

1.2 Initialize Git

$ git init
$ git config user.name "Your Name"
$ git config user.email "your@email.com"

1.3 Create Directory Structure

$ mkdir -p css js assets/icons

// Part 2: The Code

Now let's build the dashboard components.

2.1 Create services.json

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"
    }
  ]
}

2.2 Create index.php

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"/>';
}
?>

2.3 Create CSS

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;
    }
}

2.4 Create JavaScript

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);

// Part 3: Docker Deployment

Containerize your dashboard for easy deployment.

3.1 Create Dockerfile

$ 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"]

3.2 Create docker-compose.yml

$ 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"

3.3 Build and Run

$ docker-compose up -d --build
# Build and start the container
$ docker-compose logs -f
# View logs

// Part 4: Deploy to VPS

Get your dashboard online.

4.1 Push to Git

$ git add .
$ git commit -m "Initial homelab dashboard"
$ git remote add origin git@github.com:yourusername/homelab-dashboard.git
$ git push -u origin main

4.2 Clone on Server

$ ssh user@your-server
$ mkdir -p ~/homelab && cd ~/homelab
$ git clone git@github.com:yourusername/homelab-dashboard.git .

4.3 Run with Docker

$ docker-compose up -d --build

4.4 Set Up Nginx Reverse Proxy

$ 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

4.5 Add SSL with Certbot

$ sudo certbot --nginx -d dashboard.example.com

// What's Next?

Your dashboard is just the beginning. Here's how to expand it:

Alternative Solutions

If you'd prefer ready-made solutions, check out: