Keep all your Docker services organized in one place. A modern, self-hosted dashboard designed for Docker deployments. Quick access to your apps with a clean UI, favorites, dark mode, and persistent SQLite storage.
Security note: Includes admin login with rate limiting and timing-attack protection. Designed for private networks behind reverse proxy. Do not expose directly to the internet without additional security measures.
- Quick access dashboard for all your Docker services
- One-click open in new tab
- Favorites system - Pin important apps to the top
- Favorites section - Favorites grouped above the main list
- Category system - Organize apps with custom categories/tags
- App descriptions - Add notes and details about each app
- Search & filter - Find apps by name, URL, category, or description
- Category filter - Dropdown to filter apps by category
- Category autocomplete - Suggests existing categories when typing
- Backup export/import (ZIP) - Full backup with JSON + uploaded images
- Input validation - Category max 50 chars, description max 500 chars
- Optional thumbnails (jpg/png/webp, max 1024x1024, 1MB)
- Collapsible form - Clean interface when not editing
- Grid & List views - Toggle between card grid (default) and compact list layout
- View persistence - Your layout preference is remembered
- Modern grid layout - Responsive cards (3 cols β 2 β 1)
- Dark mode - Auto/Light/Dark theme selector with persistence
- Smooth animations - Fade-in, scale, hover effects
- Empty states - Contextual messages for no apps vs no search results
- Loading states - Visual feedback for all operations
- Pagination - 6 apps per page with navigation controls
- Responsive pagination - Compact label and controls on narrow screens
- SQLite persistence - No external database needed
- Docker-first - Ready for Docker/Portainer deployment
- Session persistence - Login lasts 30 days across browser restarts
- Rate limiting - Protection against brute force (5 attempts/15min)
- Timing-attack protection - Secure credential comparison
- URL validation - Only valid HTTP/HTTPS
- Image validation - Size, dimensions, and format checks
- Input length validation - Server-side limits on category and description
- Backup validation - ZIP import validation (structure, size, and image checks)
- Auto-migration - Database schema upgrades automatically on startup
- Graceful shutdown - Clean database connection handling
- Health check endpoint - Database connectivity verification
- Backend: Node.js 20 + Express
- Database: SQLite 3
- Frontend: Vanilla HTML/CSS/JS + Lucide Icons
- Deployment: Docker + docker-compose
- Create
.envfile with your credentials:
# Generate a secure session secret
openssl rand -hex 32PORT=9500
DB_PATH=/app/data/homelinks.sqlite
UPLOAD_DIR=/app/data/uploads
MAX_IMAGE_SIZE=1024
MAX_IMAGE_BYTES=1048576
ADMIN_EMAIL=your@email.com
ADMIN_PASSWORD=your_secure_password
SESSION_SECRET=your_generated_secret_here
COOKIE_SECURE=false
TRUST_PROXY=false- Start with docker-compose:
docker compose up -d- Access the dashboard:
- Open http://localhost:9500
- Login with your credentials
- Start adding your Docker services!
services:
homelinks:
image: ghcr.io/artcc/homelinks:latest
container_name: homelinks
ports:
- "${PORT:-9500}:${PORT:-9500}"
environment:
- PORT=${PORT:-9500}
- DB_PATH=${DB_PATH:-/app/data/homelinks.sqlite}
- UPLOAD_DIR=${UPLOAD_DIR:-/app/data/uploads}
- MAX_IMAGE_SIZE=${MAX_IMAGE_SIZE:-1024}
- MAX_IMAGE_BYTES=${MAX_IMAGE_BYTES:-1048576}
- ADMIN_EMAIL=${ADMIN_EMAIL:?}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?}
- SESSION_SECRET=${SESSION_SECRET:?}
- COOKIE_SECURE=${COOKIE_SECURE:-false}
- TRUST_PROXY=${TRUST_PROXY:-false}
volumes:
- ${DATA_DIR:-./data}:/app/data
restart: unless-stopped- Go to Stacks β Add stack
- Paste the docker-compose.yml above
- Add environment variables in the Environment variables section
- Click Deploy the stack
To pull the latest image manually:
docker pull ghcr.io/artcc/homelinks:latestBefore deploying, generate a strong random secret:
# Using Node.js (recommended)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Or using OpenSSL
openssl rand -hex 32Copy the output and use it as your SESSION_SECRET value.
| Variable | Description | Required | Default |
|---|---|---|---|
PORT |
Server port | No | 9500 |
DB_PATH |
SQLite database path | No | ./data/homelinks.sqlite |
DATA_DIR |
Host data directory for Docker volume | No | ./data |
UPLOAD_DIR |
Uploads directory | No | ./data/uploads |
MAX_IMAGE_SIZE |
Max image dimensions (px) | No | 1024 |
MAX_IMAGE_BYTES |
Max image file size (bytes) | No | 1048576 (1MB) |
ADMIN_EMAIL |
Admin email for login | Yes | - |
ADMIN_PASSWORD |
Admin password (plain text) | Yes | - |
SESSION_SECRET |
Session secret | Yes | - |
COOKIE_SECURE |
Set true behind HTTPS |
No | false |
TRUST_PROXY |
Set true behind a reverse proxy (Traefik, Nginx, etc.) |
No | false |
Create a .env file based on .env.example:
PORT=9500
DB_PATH=/app/data/homelinks.sqlite
DATA_DIR=./data
UPLOAD_DIR=/app/data/uploads
MAX_IMAGE_SIZE=1024
MAX_IMAGE_BYTES=1048576
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=
SESSION_SECRET=change-me
COOKIE_SECURE=false
TRUST_PROXY=falseStore the admin password in ADMIN_PASSWORD (plain text).
Important: Replace SESSION_SECRET=change-me with a secure random string generated with OpenSSL or Node.js (see Quick Start).
The SQLite database is stored at DB_PATH and uploads go to UPLOAD_DIR. With Docker, map a volume (e.g. ./data:/app/data) so data survives container restarts.
If you create a host directory like /opt/docker/homelinks/data, these permissions work with the default container user (root):
sudo chown -R root:root /opt/docker/homelinks
sudo chmod 755 /opt/docker/homelinks
sudo chmod 755 /opt/docker/homelinks/dataIf you prefer running as a non-root user (e.g. 1000:1000), use:
sudo chown -R 1000:1000 /opt/docker/homelinks
sudo chmod 775 /opt/docker/homelinks
sudo chmod 775 /opt/docker/homelinks/data- Rate limiting: Maximum 5 login attempts per 15 minutes per IP
- Timing-attack protection: Uses
crypto.timingSafeEqualfor credential comparison - URL validation: Only valid HTTP/HTTPS URLs are accepted
- Image validation: Size, dimensions, and format checks
- Input length validation: Category (50 chars) and description (500 chars) limits enforced server-side
- Session security: HttpOnly cookies with SameSite protection (30-day duration)
- SQL injection prevention: Prepared statements in all database queries
- Graceful shutdown: Proper database connection cleanup on termination
GET /api/session- Check if user is authenticatedPOST /api/login- Login with email and password (rate limited)POST /api/logout- Logout and destroy session
GET /api/apps- List all apps (authenticated, ordered by favorite then name)GET /api/apps/categories- List all unique categories (authenticated)POST /api/apps- Create new app (authenticated, multipart form withname,url, optionalimage,category,description)PUT /api/apps/:id- Update app (authenticated, multipart form withname,url, optionalimage,category,description)PATCH /api/apps/:id/favorite- Toggle favorite status (authenticated)DELETE /api/apps/:id- Delete app (authenticated)GET /api/apps/export- Export full backup as ZIP (authenticated)POST /api/apps/import- Import full backup from ZIP and replace all existing apps/images (authenticated, max 50MB)
Export creates a ZIP file with:
apps.json- Metadata + apps arrayuploads/- App images referenced byimage_url
Import behavior:
- Replace all mode: current apps are replaced by the backup content
- Existing uploads from previous apps are removed after successful import
- ZIP size limit: 50MB
- Image rules remain enforced (jpg/png/webp, max 1024x1024, 1MB each)
GET /health- Health status with database connectivity check
Returns:
{
"ok": true,
"timestamp": "2026-02-09T16:30:00.000Z",
"status": "healthy"
}If you see this error in the logs when running behind Traefik, Nginx, or another reverse proxy:
ValidationError: The 'X-Forwarded-For' header is set but the Express 'trust proxy' setting is false
Set TRUST_PROXY=true in your .env file to enable Express's trust proxy setting.
Wait 15 minutes or restart the container to reset the rate limiter.
Ensure only one instance is accessing the database. SQLite doesn't support multiple concurrent writers.
Check that:
- The
UPLOAD_DIRpermissions are correct - The volume mapping in Docker is set up properly
- Images are <= 1024x1024 pixels and <= 1MB
Rebuild the Docker image to include the latest path fixes:
docker compose build --no-cache
docker compose up -dSee CHANGELOG.md.
This project is licensed under the Apache 2.0 License - see LICENSE.
Arturo Carretero Calvo β 2026