Files
dashcaddy/dashcaddy-installer/install.sh
Sami ffa6966fd3 Add auto-update system for DashCaddy instances
- self-updater.js: polls for new versions, downloads/verifies tarballs,
  triggers host-side rebuild via systemd path unit
- dashcaddy-update.sh + systemd units: host-side container rebuild with
  automatic rollback on health check failure
- 7 new /api/v1/system/* endpoints for version info, update check/apply,
  rollback, and update history
- Frontend: DashCaddy tab in Updates modal with version display,
  changelog, update button, rollback, and notification dot
- install.sh: updater service installation, volume mounts, env vars
- build-release.sh + webhook-handler.js: release server pipeline
  (Gitea webhook → build tarball → deploy to get.dashcaddy.net)
- Dockerfile: DASHCADDY_COMMIT build arg → VERSION file
- Version bump to 1.1.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:11:35 -08:00

1042 lines
33 KiB
Bash

#!/usr/bin/env bash
# ============================================================================
# DashCaddy Linux Installer v1.0
#
# Zero-effort install:
# curl -fsSL https://get.dashcaddy.net | bash
#
# Zero-typing install (local mode, instant access):
# curl -fsSL https://get.dashcaddy.net | bash -s -- quick
#
# With a domain (only thing you type is the domain):
# curl -fsSL https://get.dashcaddy.net | bash -s -- --domain my.example.com
#
# Designed for accessibility — all choices are single keystrokes.
# ============================================================================
set -euo pipefail
# ---- Constants -------------------------------------------------------------
readonly DASHCADDY_VERSION="1.1.0"
readonly DASHCADDY_DOWNLOAD="https://get.dashcaddy.net/release/latest.tar.gz"
readonly DASHCADDY_REPO="" # Set to a git URL to clone instead of downloading
readonly INSTALL_DIR="/etc/dashcaddy"
readonly DOCKER_DATA="/opt/dockerdata"
readonly SITES_DIR="${INSTALL_DIR}/sites"
readonly API_DIR="${SITES_DIR}/dashcaddy-api"
readonly DASHBOARD_DIR="${SITES_DIR}/status"
readonly CONTAINER_NAME="dashcaddy-api"
readonly CADDY_ADMIN_PORT=2019
# ---- Tunables (overridable via flags) --------------------------------------
API_PORT=3001
LOCAL_PORT=8080
# ---- Runtime state ---------------------------------------------------------
DOMAIN_MODE="" # public | custom-tld | local
DOMAIN=""
EMAIL=""
TLD=""
CA_NAME="DashCaddy Local CA"
SOURCE_PATH=""
GIT_BRANCH="main"
SKIP_DOCKER=false
SKIP_CADDY=false
UNINSTALL=false
KEEP_CONFIG=false
AUTO_YES=false
QUICK=false
DISTRO=""
DISTRO_FAMILY="" # debian | rhel | arch
PUBLIC_IP=""
LAN_IP=""
STEP=0
TOTAL_STEPS=7
# ---- Colors ----------------------------------------------------------------
if [[ -t 1 ]]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'
DIM='\033[2m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''; BOLD=''; DIM=''; NC=''
fi
# ============================================================================
# Output Helpers
# ============================================================================
log() { echo -e "${CYAN}[DashCaddy]${NC} $*"; }
ok() { echo -e "${GREEN}${NC} $*"; }
warn() { echo -e "${YELLOW} !${NC} $*"; }
err() { echo -e "${RED}${NC} $*" >&2; }
fatal() { err "$*"; echo -e "${DIM} If this keeps failing, see: https://dashcaddy.net/docs/troubleshoot${NC}" >&2; exit 1; }
step() {
STEP=$((STEP + 1))
echo ""
echo -e "${BOLD}${BLUE} [${STEP}/${TOTAL_STEPS}] $*${NC}"
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
}
# Simple progress: run command in background, show dots
progress() {
local msg="$1"; shift
printf "${CYAN}${NC} %s " "$msg"
# Run the command, capture output for error reporting
local logfile="/tmp/dashcaddy-install-$$.log"
if "$@" >"$logfile" 2>&1; then
echo -e "${GREEN}done${NC}"
rm -f "$logfile"
return 0
else
echo -e "${RED}failed${NC}"
echo -e "${DIM} Last output:${NC}"
tail -5 "$logfile" 2>/dev/null | sed 's/^/ /'
rm -f "$logfile"
return 1
fi
}
elapsed() {
local diff=$(( $(date +%s) - $1 ))
if (( diff < 60 )); then
echo "${diff}s"
else
printf '%dm%02ds' $((diff / 60)) $((diff % 60))
fi
}
# ============================================================================
# System Detection
# ============================================================================
detect_system() {
# --- Root check ---
if [[ $EUID -ne 0 ]]; then
fatal "Must run as root. Try: sudo bash install.sh"
fi
# --- OS detection ---
if [[ ! -f /etc/os-release ]]; then
fatal "Cannot detect OS — /etc/os-release not found."
fi
source /etc/os-release
case "${ID,,}" in
ubuntu|debian|pop|linuxmint|elementary|zorin|raspbian)
DISTRO="${ID}"; DISTRO_FAMILY="debian" ;;
fedora|rhel|centos|rocky|almalinux|ol|amzn)
DISTRO="${ID}"; DISTRO_FAMILY="rhel" ;;
arch|manjaro|endeavouros)
DISTRO="${ID}"; DISTRO_FAMILY="arch" ;;
*)
DISTRO="${ID}"; DISTRO_FAMILY="debian"
warn "Unknown distro '${ID}' — using Debian-style commands" ;;
esac
ok "OS: ${PRETTY_NAME:-$DISTRO}"
# --- Architecture ---
local arch
arch=$(uname -m)
case "$arch" in
x86_64|amd64|aarch64|arm64) ok "Arch: ${arch}" ;;
*) warn "Arch '${arch}' may not be fully supported" ;;
esac
# --- Network ---
LAN_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' || hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
PUBLIC_IP=$(curl -fsSL --max-time 3 https://api.ipify.org 2>/dev/null || curl -fsSL --max-time 3 https://ifconfig.me 2>/dev/null || echo "unknown")
ok "LAN IP: ${LAN_IP}"
ok "Public IP: ${PUBLIC_IP}"
# --- Existing install? ---
if [[ -f "${INSTALL_DIR}/config.json" ]]; then
warn "Existing DashCaddy installation detected"
warn "Re-running will upgrade in place (configs preserved)"
fi
}
# ============================================================================
# Interactive Setup — minimal typing, number keys only
# ============================================================================
interactive_setup() {
# Skip if mode already set via flags
if [[ -n "$DOMAIN_MODE" ]]; then return; fi
# Detect environment to offer smart defaults
local is_vps=false
if [[ "$PUBLIC_IP" != "unknown" && "$PUBLIC_IP" != "$LAN_IP" ]] || \
[[ "$LAN_IP" =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.) ]]; then
: # Private IP — probably homelab or local
else
is_vps=true
fi
echo ""
echo -e "${BOLD} How will you access DashCaddy?${NC}"
echo ""
if $is_vps; then
echo -e " ${BOLD}1${NC}) Public domain ${DIM}— Let's Encrypt TLS (recommended for VPS)${NC}"
echo -e " ${BOLD}2${NC}) Quick start ${DIM}— http://${PUBLIC_IP}:${LOCAL_PORT} right now, add domain later${NC}"
echo -e " ${BOLD}3${NC}) Custom TLD ${DIM}— Internal CA for homelab (e.g., .home, .lab)${NC}"
else
echo -e " ${BOLD}1${NC}) Quick start ${DIM}— http://${LAN_IP}:${LOCAL_PORT} right now${NC}"
echo -e " ${BOLD}2${NC}) Custom TLD ${DIM}— Internal CA for homelab (e.g., .home, .lab)${NC}"
echo -e " ${BOLD}3${NC}) Public domain ${DIM}— Let's Encrypt TLS${NC}"
fi
echo ""
local choice
read -rp "$(echo -e " ${YELLOW}Press 1, 2, or 3:${NC} ")" -n1 choice
echo ""
if $is_vps; then
case "$choice" in
1) setup_public_domain ;;
2) DOMAIN_MODE="local" ;;
3) setup_custom_tld ;;
*) DOMAIN_MODE="local"; warn "Defaulting to Quick Start" ;;
esac
else
case "$choice" in
1) DOMAIN_MODE="local" ;;
2) setup_custom_tld ;;
3) setup_public_domain ;;
*) DOMAIN_MODE="local"; warn "Defaulting to Quick Start" ;;
esac
fi
}
setup_public_domain() {
DOMAIN_MODE="public"
echo ""
read -rp "$(echo -e " ${YELLOW}Domain name:${NC} ")" DOMAIN
if [[ -z "$DOMAIN" ]]; then
fatal "Domain is required for public mode."
fi
# Auto-check if domain points to this server
local resolved
resolved=$(dig +short "$DOMAIN" 2>/dev/null | head -1)
if [[ -n "$resolved" && "$resolved" == "$PUBLIC_IP" ]]; then
ok "${DOMAIN} correctly points to ${PUBLIC_IP}"
elif [[ -n "$resolved" ]]; then
warn "${DOMAIN} resolves to ${resolved}, but this server is ${PUBLIC_IP}"
echo -e " ${DIM}Let's Encrypt will fail if DNS doesn't point here.${NC}"
echo ""
read -rp "$(echo -e " ${YELLOW}Continue anyway? [y/N]:${NC} ")" -n1 cont
echo ""
[[ "$cont" =~ ^[Yy]$ ]] || fatal "Fix DNS first, then re-run the installer."
else
warn "Could not verify DNS for ${DOMAIN}"
echo -e " ${DIM}Make sure ${DOMAIN} points to ${PUBLIC_IP} for TLS to work.${NC}"
fi
# Email — offer skip
echo ""
echo -e " ${DIM}Email for Let's Encrypt notifications (optional, press Enter to skip):${NC}"
read -rp " " EMAIL
}
setup_custom_tld() {
DOMAIN_MODE="custom-tld"
echo ""
echo -e " ${DIM}Common choices: .home .local .lab .lan${NC}"
read -rp "$(echo -e " ${YELLOW}TLD (default .home):${NC} ")" TLD
[[ -z "$TLD" ]] && TLD=".home"
[[ "$TLD" != .* ]] && TLD=".$TLD"
echo ""
read -rp "$(echo -e " ${YELLOW}CA name (default: DashCaddy Local CA):${NC} ")" input
[[ -n "$input" ]] && CA_NAME="$input"
}
# ============================================================================
# Dependency Installation
# ============================================================================
install_prereqs() {
# Only install what's missing
local needed=()
command -v curl &>/dev/null || needed+=(curl)
command -v jq &>/dev/null || needed+=(jq)
command -v dig &>/dev/null || needed+=(dnsutils)
# git only needed if using --source with a repo URL
[[ -n "$DASHCADDY_REPO" ]] && ! command -v git &>/dev/null && needed+=(git)
if [[ ${#needed[@]} -eq 0 ]]; then
ok "Prerequisites already installed"
return
fi
progress "Installing ${needed[*]}..." bash -c "
case '$DISTRO_FAMILY' in
debian) apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ${needed[*]} ;;
rhel) dnf install -y -q ${needed[*]} 2>/dev/null || yum install -y -q ${needed[*]} ;;
arch) pacman -Sy --noconfirm ${needed[*]} ;;
esac
" || warn "Some prerequisites may not have installed"
}
install_docker() {
if $SKIP_DOCKER; then ok "Docker: skipped (--skip-docker)"; return; fi
if command -v docker &>/dev/null && docker info &>/dev/null; then
ok "Docker: $(docker --version | grep -oP 'Docker version [\d.]+')"
return
fi
progress "Installing Docker (this is the slowest step)..." bash -c "
case '$DISTRO_FAMILY' in
debian)
curl -fsSL https://get.docker.com | sh
;;
rhel)
if command -v dnf &>/dev/null; then
dnf install -y -q dnf-plugins-core
dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
dnf install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin
else
yum install -y -q yum-utils
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin
fi
;;
arch)
pacman -Sy --noconfirm docker docker-compose
;;
esac
systemctl enable docker
systemctl start docker
" || fatal "Docker installation failed. Install manually: https://docs.docker.com/engine/install/"
if docker info &>/dev/null; then
ok "Docker: $(docker --version | grep -oP 'Docker version [\d.]+')"
else
fatal "Docker installed but daemon not running. Try: systemctl start docker"
fi
}
install_caddy() {
if $SKIP_CADDY; then ok "Caddy: skipped (--skip-caddy)"; return; fi
if command -v caddy &>/dev/null; then
ok "Caddy: $(caddy version 2>/dev/null | head -1)"
return
fi
progress "Installing Caddy..." bash -c "
case '$DISTRO_FAMILY' in
debian)
apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null
apt-get update -qq
apt-get install -y -qq caddy
;;
rhel)
if command -v dnf &>/dev/null; then
dnf install -y -q 'dnf-command(copr)'
dnf copr enable -y @caddy/caddy
dnf install -y -q caddy
else
yum install -y -q yum-plugin-copr
yum copr enable -y @caddy/caddy
yum install -y -q caddy
fi
;;
arch)
pacman -Sy --noconfirm caddy
;;
esac
# Stop default Caddy — we configure it ourselves
systemctl stop caddy 2>/dev/null || true
" || fatal "Caddy installation failed. Install manually: https://caddyserver.com/docs/install"
ok "Caddy: $(caddy version 2>/dev/null | head -1)"
}
# ============================================================================
# Fix known OS issues automatically
# ============================================================================
fix_system_issues() {
# systemd-resolved stub — breaks Docker DNS and Tailscale
if [[ -L /etc/resolv.conf ]] && readlink /etc/resolv.conf | grep -q stub; then
rm -f /etc/resolv.conf
cat > /etc/resolv.conf <<'EOF'
# Set by DashCaddy installer (replaced systemd-resolved stub)
nameserver 1.1.1.1
nameserver 8.8.8.8
nameserver 1.0.0.1
EOF
ok "Fixed systemd-resolved stub (replaced with public DNS)"
fi
# Ensure /etc/caddy exists
mkdir -p /etc/caddy
}
# ============================================================================
# Directory & File Setup
# ============================================================================
create_directories() {
mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets"
mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts
ok "Directories created"
}
fetch_source() {
local tmp_src="/tmp/dashcaddy-src-$$"
if [[ -n "$SOURCE_PATH" ]]; then
if [[ ! -d "$SOURCE_PATH" ]]; then
fatal "Source path not found: $SOURCE_PATH"
fi
tmp_src="$SOURCE_PATH"
ok "Using local source: $SOURCE_PATH"
elif [[ -n "$DASHCADDY_REPO" ]]; then
# Git clone mode (for development)
progress "Cloning DashCaddy..." \
git clone --depth 1 --branch "$GIT_BRANCH" "$DASHCADDY_REPO" "$tmp_src" \
|| fatal "Failed to clone DashCaddy. Check network."
else
# Download release tarball (default — no git needed)
mkdir -p "$tmp_src"
progress "Downloading DashCaddy v${DASHCADDY_VERSION}..." bash -c "
curl -fsSL '${DASHCADDY_DOWNLOAD}' | tar xz -C '$tmp_src' --strip-components=1
" || fatal "Failed to download DashCaddy. Check network or try --source with a local copy."
fi
# Find API source (handle different repo layouts)
local api_src=""
for try in "${tmp_src}/dashcaddy-api" "${tmp_src}/sites/dashcaddy-api" "${tmp_src}/api"; do
[[ -d "$try" ]] && api_src="$try" && break
done
[[ -z "$api_src" ]] && fatal "Cannot find dashcaddy-api/ in source"
# Find dashboard source
local dash_src=""
for try in "${tmp_src}/status" "${tmp_src}/sites/status" "${tmp_src}/dashboard"; do
[[ -d "$try" ]] && dash_src="$try" && break
done
[[ -z "$dash_src" ]] && fatal "Cannot find dashboard source in source"
# Deploy API files
cp -f "${api_src}"/*.js "$API_DIR/" 2>/dev/null || true
cp -f "${api_src}/package.json" "$API_DIR/"
cp -f "${api_src}/package-lock.json" "$API_DIR/" 2>/dev/null || true
cp -f "${api_src}/Dockerfile" "$API_DIR/"
cp -f "${api_src}/openapi.yaml" "$API_DIR/" 2>/dev/null || true
[[ -d "${api_src}/routes" ]] && cp -rf "${api_src}/routes" "$API_DIR/"
ok "API files deployed"
# Deploy dashboard files
cp -f "${dash_src}/index.html" "$DASHBOARD_DIR/"
cp -f "${dash_src}/sw.js" "$DASHBOARD_DIR/" 2>/dev/null || true
for dir in css js dist vendor assets; do
[[ -d "${dash_src}/${dir}" ]] && cp -rf "${dash_src}/${dir}" "$DASHBOARD_DIR/"
done
ok "Dashboard files deployed"
# Deploy updater scripts
local scripts_src=""
for try in "${api_src}/scripts" "${tmp_src}/scripts"; do
[[ -d "$try" ]] && scripts_src="$try" && break
done
if [[ -n "$scripts_src" ]]; then
cp -f "${scripts_src}/dashcaddy-update.sh" /opt/dashcaddy/scripts/ 2>/dev/null || true
cp -f "${scripts_src}/dashcaddy-updater.path" /opt/dashcaddy/scripts/ 2>/dev/null || true
cp -f "${scripts_src}/dashcaddy-updater.service" /opt/dashcaddy/scripts/ 2>/dev/null || true
chmod +x /opt/dashcaddy/scripts/dashcaddy-update.sh 2>/dev/null || true
ok "Updater scripts deployed"
fi
# Cleanup
[[ -z "$SOURCE_PATH" ]] && rm -rf "$tmp_src"
}
create_seed_configs() {
local config_type dashboard_host tz
case "$DOMAIN_MODE" in
public) config_type="public"; dashboard_host="$DOMAIN" ;;
custom-tld) config_type="homelab"; dashboard_host="dashcaddy${TLD}" ;;
local) config_type="local"; dashboard_host="${LAN_IP}:${LOCAL_PORT}" ;;
esac
tz=$(timedatectl show --property=Timezone --value 2>/dev/null || echo 'UTC')
# Only create files that don't already exist (preserve on re-install)
[[ -f "${INSTALL_DIR}/services.json" ]] || echo '[]' > "${INSTALL_DIR}/services.json"
[[ -f "${INSTALL_DIR}/dns-credentials.json" ]] || echo '{}' > "${INSTALL_DIR}/dns-credentials.json"
[[ -f "${INSTALL_DIR}/credentials.json" ]] || echo '{}' > "${INSTALL_DIR}/credentials.json"
[[ -f "${INSTALL_DIR}/notifications.json" ]] || echo '[]' > "${INSTALL_DIR}/notifications.json"
if [[ ! -f "${INSTALL_DIR}/.encryption-key" ]]; then
openssl rand -hex 32 > "${INSTALL_DIR}/.encryption-key"
chmod 600 "${INSTALL_DIR}/.encryption-key"
fi
# config.json — always regenerate (contains dashboard URL which may change)
cat > "${INSTALL_DIR}/config.json" <<CFGEOF
{
"setupComplete": true,
"configurationType": "${config_type}",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)",
"timezone": "${tz}",
"dashboardTitle": "DashCaddy",
"dashboardHost": "${dashboard_host}",
"tld": "${TLD}",
"caName": "${CA_NAME}",
"license": {
"active": true,
"tier": "community",
"features": []
}
}
CFGEOF
ok "Config files ready"
}
# ============================================================================
# Caddyfile Generation
# ============================================================================
generate_caddyfile() {
local cf="${INSTALL_DIR}/Caddyfile"
# --- Shared snippets ---
local snippets
read -r -d '' snippets <<'SNIP' || true
(cors-preflight) {
@preflight method OPTIONS
header @preflight {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "*"
Access-Control-Max-Age "600"
}
respond @preflight 204
}
(cors-allow) {
header {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "*"
Access-Control-Max-Age "600"
}
}
SNIP
local auth_snippet="(dashcaddy_auth) {
forward_auth localhost:${API_PORT} {
uri /api/auth/gate/{args[0]}
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
}
}"
local site_body=" root * ${DASHBOARD_DIR}
encode gzip
handle /api/* {
reverse_proxy localhost:${API_PORT}
}
handle {
@notFile not file {path}
rewrite @notFile /index.html
file_server
}"
# --- Mode-specific Caddyfile ---
case "$DOMAIN_MODE" in
public)
cat > "$cf" <<CEOF
# DashCaddy - Public Domain (Let's Encrypt)
{
admin localhost:${CADDY_ADMIN_PORT}
$([ -n "$EMAIL" ] && echo " email ${EMAIL}")
}
${snippets}
${auth_snippet}
${DOMAIN} {
${site_body}
}
CEOF
;;
custom-tld)
cat > "$cf" <<CEOF
# DashCaddy - Custom TLD (${TLD})
{
admin localhost:${CADDY_ADMIN_PORT}
pki {
ca local {
name "${CA_NAME}"
root_cn "${CA_NAME} Root CA"
intermediate_cn "${CA_NAME} Intermediate CA"
}
}
}
${snippets}
${auth_snippet}
dashcaddy${TLD} {
tls internal
${site_body}
}
CEOF
;;
local)
cat > "$cf" <<CEOF
# DashCaddy - Local Mode
{
admin localhost:${CADDY_ADMIN_PORT}
auto_https off
}
${snippets}
:${LOCAL_PORT} {
${site_body}
}
CEOF
;;
esac
ok "Caddyfile generated"
}
# ============================================================================
# Docker Compose
# ============================================================================
generate_docker_compose() {
cat > "${API_DIR}/docker-compose.yml" <<DCEOF
services:
dashcaddy-api:
build: .
container_name: ${CONTAINER_NAME}
ports:
- "${API_PORT}:${API_PORT}"
volumes:
- ${INSTALL_DIR}/Caddyfile:/caddyfile:rw
- ${INSTALL_DIR}/services.json:/app/services.json:rw
- ${INSTALL_DIR}/dns-credentials.json:/app/dns-credentials.json:rw
- ${INSTALL_DIR}/config.json:/app/config.json:rw
- ${INSTALL_DIR}/credentials.json:/app/credentials.json:rw
- ${INSTALL_DIR}/.encryption-key:/app/.encryption-key:rw
- ${INSTALL_DIR}/.license-secret:/app/.license-secret:ro
- ${INSTALL_DIR}/totp-config.json:/app/totp-config.json:rw
- ${INSTALL_DIR}/notifications.json:/app/notifications.json:rw
- ${DASHBOARD_DIR}/assets:/app/assets:rw
- ${DASHBOARD_DIR}:/app/dashboard:rw
- /opt/dashcaddy/updates:/app/updates:rw
- /var/run/docker.sock:/var/run/docker.sock
environment:
- CADDYFILE_PATH=/caddyfile
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
- ASSETS_PATH=/app/assets
- CREDENTIALS_FILE=/app/credentials.json
- CONFIG_FILE=/app/config.json
- SERVICES_FILE=/app/services.json
- DNS_CREDENTIALS_FILE=/app/dns-credentials.json
- HOST_LAN_IP=${LAN_IP}
- NODE_ENV=production
- DASHCADDY_UPDATE_ENABLED=true
- DASHCADDY_UPDATE_URL=https://get.dashcaddy.net/release
- DASHCADDY_MIRROR_URL=https://get2.dashcaddy.net/release
- DASHCADDY_UPDATES_DIR=/app/updates
- DASHCADDY_API_SOURCE_DIR=${API_DIR}
- DASHCADDY_FRONTEND_DIR=/app/dashboard
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
DCEOF
ok "docker-compose.yml generated"
}
# ============================================================================
# Auto-Updater Service
# ============================================================================
install_updater_service() {
local scripts_dir="/opt/dashcaddy/scripts"
if [[ ! -f "${scripts_dir}/dashcaddy-update.sh" ]]; then
warn "Updater script not found — skipping auto-update service"
return
fi
# Install systemd units
if [[ -f "${scripts_dir}/dashcaddy-updater.path" ]]; then
cp -f "${scripts_dir}/dashcaddy-updater.path" /etc/systemd/system/
else
cat > /etc/systemd/system/dashcaddy-updater.path <<'PATHEOF'
[Unit]
Description=Watch for DashCaddy update trigger
[Path]
PathChanged=/opt/dashcaddy/updates/trigger.json
MakeDirectory=yes
[Install]
WantedBy=multi-user.target
PATHEOF
fi
if [[ -f "${scripts_dir}/dashcaddy-updater.service" ]]; then
cp -f "${scripts_dir}/dashcaddy-updater.service" /etc/systemd/system/
else
cat > /etc/systemd/system/dashcaddy-updater.service <<'SVCEOF'
[Unit]
Description=DashCaddy auto-update handler
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/opt/dashcaddy/scripts/dashcaddy-update.sh
TimeoutStartSec=300
StandardOutput=journal
StandardError=journal
SyslogIdentifier=dashcaddy-update
SVCEOF
fi
systemctl daemon-reload
systemctl enable dashcaddy-updater.path >/dev/null 2>&1
systemctl start dashcaddy-updater.path >/dev/null 2>&1
ok "Auto-updater service installed"
}
# ============================================================================
# Build & Launch
# ============================================================================
build_and_start() {
# Remove old container if exists
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
cd "$API_DIR"
progress "Building container image (30-60s)..." \
docker compose build --quiet \
|| progress "Building container image (fallback)..." \
docker-compose build --quiet \
|| fatal "Docker build failed. Check: docker info"
progress "Starting container..." \
docker compose up -d \
|| docker-compose up -d 2>/dev/null \
|| fatal "Failed to start container"
# Wait for healthy
local i=0
printf "${CYAN}${NC} Waiting for API health check "
while (( i < 30 )); do
if curl -fsSL --max-time 2 "http://localhost:${API_PORT}/health" &>/dev/null; then
echo -e "${GREEN}healthy${NC}"
return
fi
printf "."
sleep 1
i=$((i + 1))
done
echo -e "${YELLOW}timeout${NC}"
warn "API may still be starting. Check: docker logs ${CONTAINER_NAME}"
}
start_caddy() {
# Symlink our Caddyfile to where Caddy's systemd unit expects it
if [[ -f /etc/caddy/Caddyfile && ! -L /etc/caddy/Caddyfile ]]; then
mv /etc/caddy/Caddyfile /etc/caddy/Caddyfile.original 2>/dev/null || true
fi
ln -sf "${INSTALL_DIR}/Caddyfile" /etc/caddy/Caddyfile
systemctl enable caddy >/dev/null 2>&1
systemctl restart caddy 2>/dev/null
sleep 2
if systemctl is-active --quiet caddy; then
ok "Caddy is running"
else
warn "Caddy may have issues. Check: systemctl status caddy"
fi
}
# ============================================================================
# Firewall
# ============================================================================
open_firewall() {
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "active"; then
case "$DOMAIN_MODE" in
public)
ufw allow 80/tcp >/dev/null 2>&1; ufw allow 443/tcp >/dev/null 2>&1
ok "Firewall: opened 80, 443 (UFW)" ;;
local)
ufw allow "${LOCAL_PORT}"/tcp >/dev/null 2>&1
ok "Firewall: opened ${LOCAL_PORT} (UFW)" ;;
esac
elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld 2>/dev/null; then
case "$DOMAIN_MODE" in
public)
firewall-cmd --permanent --add-service=http --add-service=https >/dev/null 2>&1
firewall-cmd --reload >/dev/null 2>&1
ok "Firewall: opened 80, 443 (firewalld)" ;;
local)
firewall-cmd --permanent --add-port="${LOCAL_PORT}"/tcp >/dev/null 2>&1
firewall-cmd --reload >/dev/null 2>&1
ok "Firewall: opened ${LOCAL_PORT} (firewalld)" ;;
esac
fi
}
# ============================================================================
# Uninstall
# ============================================================================
do_uninstall() {
echo -e "\n${BOLD} Uninstalling DashCaddy${NC}\n"
echo " This will:"
echo " - Stop and remove the dashcaddy-api container"
echo " - Remove DashCaddy files from ${INSTALL_DIR}/"
if $KEEP_CONFIG; then
echo " - KEEP your config files (--keep-config)"
fi
echo " - NOT remove Docker or Caddy"
echo ""
read -rp "$(echo -e " ${YELLOW}Continue? [y/N]:${NC} ")" -n1 answer
echo ""
[[ "$answer" =~ ^[Yy]$ ]] || { echo " Cancelled."; exit 0; }
docker rm -f "$CONTAINER_NAME" 2>/dev/null && ok "Container removed" || true
# Stop and remove updater service
systemctl stop dashcaddy-updater.path 2>/dev/null || true
systemctl disable dashcaddy-updater.path 2>/dev/null || true
rm -f /etc/systemd/system/dashcaddy-updater.path /etc/systemd/system/dashcaddy-updater.service
systemctl daemon-reload 2>/dev/null || true
rm -rf /opt/dashcaddy && ok "Updater service removed" || true
if [[ -L /etc/caddy/Caddyfile ]] && readlink /etc/caddy/Caddyfile | grep -q dashcaddy; then
rm -f /etc/caddy/Caddyfile
[[ -f /etc/caddy/Caddyfile.original ]] && mv /etc/caddy/Caddyfile.original /etc/caddy/Caddyfile
systemctl restart caddy 2>/dev/null || true
ok "Caddy config restored"
fi
if $KEEP_CONFIG; then
rm -rf "$API_DIR" "$DASHBOARD_DIR"
ok "App files removed, config preserved in ${INSTALL_DIR}/"
else
rm -rf "$INSTALL_DIR"
ok "All files removed from ${INSTALL_DIR}/"
fi
echo -e "\n${GREEN} DashCaddy uninstalled.${NC}\n"
exit 0
}
# ============================================================================
# Argument Parsing
# ============================================================================
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
quick) QUICK=true; DOMAIN_MODE="local"; shift ;;
--domain) DOMAIN_MODE="public"; DOMAIN="${2:-}"; shift; shift ;;
--email) EMAIL="${2:-}"; shift; shift ;;
--tld) DOMAIN_MODE="custom-tld"; TLD="${2:-}"; shift; shift ;;
--ca-name) CA_NAME="${2:-}"; shift; shift ;;
--local) DOMAIN_MODE="local"; shift ;;
--port) LOCAL_PORT="${2:-8080}"; shift; shift ;;
--api-port) API_PORT="${2:-3001}"; shift; shift ;;
--source) SOURCE_PATH="${2:-}"; shift; shift ;;
--branch) GIT_BRANCH="${2:-main}"; shift; shift ;;
--skip-docker) SKIP_DOCKER=true; shift ;;
--skip-caddy) SKIP_CADDY=true; shift ;;
--uninstall) UNINSTALL=true; shift ;;
--keep-config) KEEP_CONFIG=true; shift ;;
--yes|-y) AUTO_YES=true; shift ;;
--help|-h) print_help; exit 0 ;;
*) warn "Unknown option: $1 (ignored)"; shift ;;
esac
done
# Normalize TLD
if [[ -n "$TLD" && "$TLD" != .* ]]; then TLD=".$TLD"; fi
}
print_help() {
cat <<'HELP'
DashCaddy Linux Installer
QUICK INSTALL (zero typing):
curl -fsSL https://get.dashcaddy.net | bash -s -- quick
WITH DOMAIN:
curl -fsSL https://get.dashcaddy.net | bash -s -- --domain my.example.com
INTERACTIVE:
curl -fsSL https://get.dashcaddy.net | bash
OPTIONS:
quick Instant local install, zero questions
--domain DOMAIN Public domain (Let's Encrypt TLS)
--email EMAIL Let's Encrypt email (optional)
--tld TLD Custom TLD, internal CA (e.g., .home)
--local Local mode (http://IP:port)
--port PORT Port for local mode (default: 8080)
--source PATH Use local source files
--skip-docker Already have Docker
--skip-caddy Already have Caddy
--uninstall Remove DashCaddy
--keep-config Keep configs during uninstall
--yes Skip confirmations
HELP
}
# ============================================================================
# Banner & Success
# ============================================================================
print_banner() {
echo ""
echo -e "${BOLD}${BLUE} ╔══════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${BLUE}${NC}${BOLD} DashCaddy Installer v${DASHCADDY_VERSION} ${BLUE}${NC}"
echo -e "${BOLD}${BLUE} ╚══════════════════════════════════════════════╝${NC}"
echo ""
}
print_success() {
local total_time=$1
local url
case "$DOMAIN_MODE" in
public) url="https://${DOMAIN}" ;;
custom-tld) url="https://dashcaddy${TLD}" ;;
local) url="http://${PUBLIC_IP}:${LOCAL_PORT}" ;;
esac
# Also show LAN URL for local mode
local lan_url=""
if [[ "$DOMAIN_MODE" == "local" && "$LAN_IP" != "$PUBLIC_IP" ]]; then
lan_url="http://${LAN_IP}:${LOCAL_PORT}"
fi
echo ""
echo -e "${GREEN}${BOLD} ┌──────────────────────────────────────────────┐${NC}"
echo -e "${GREEN}${BOLD} │ Installation Complete! │${NC}"
echo -e "${GREEN}${BOLD} └──────────────────────────────────────────────┘${NC}"
echo ""
echo -e " ${BOLD}Open in browser:${NC} ${url}"
[[ -n "$lan_url" ]] && echo -e " ${BOLD}LAN access:${NC} ${lan_url}"
echo ""
echo -e " ${DIM}Config: ${INSTALL_DIR}/ | Logs: docker logs dashcaddy-api${NC}"
echo -e " ${DIM}Installed in: ${total_time}${NC}"
if [[ "$DOMAIN_MODE" == "public" ]]; then
echo ""
echo -e " ${CYAN}TLS will auto-provision on first visit.${NC}"
echo -e " ${CYAN}Ensure DNS for ${DOMAIN}${PUBLIC_IP}${NC}"
fi
echo ""
echo -e " ${BOLD}Everything is managed from the dashboard — no CLI needed.${NC}"
echo -e " ${DIM}Deploy apps, manage Docker containers, edit Caddy configs,${NC}"
echo -e " ${DIM}and monitor services all from the web UI.${NC}"
echo ""
}
# ============================================================================
# Main
# ============================================================================
main() {
local start_time
start_time=$(date +%s)
parse_args "$@"
# Handle uninstall
if $UNINSTALL; then do_uninstall; fi
print_banner
# ---- Step 1: System check ----
step "Checking system"
detect_system
fix_system_issues
# ---- Step 2: Configuration ----
if $QUICK; then
ok "Quick mode: http://${PUBLIC_IP}:${LOCAL_PORT}"
else
step "Configuration"
interactive_setup
fi
# Show what we're doing
echo ""
case "$DOMAIN_MODE" in
public) log "Installing with domain: ${DOMAIN}" ;;
custom-tld) log "Installing with TLD: ${TLD}" ;;
local) log "Installing in local mode (port ${LOCAL_PORT})" ;;
esac
# ---- Step 3: Dependencies ----
step "Installing dependencies"
install_prereqs
install_docker
install_caddy
# ---- Step 4: Deploy files ----
step "Deploying DashCaddy"
create_directories
fetch_source
create_seed_configs
# ---- Step 5: Configuration ----
step "Generating configuration"
generate_caddyfile
generate_docker_compose
open_firewall
# ---- Step 6: Build & start ----
step "Building & starting services"
build_and_start
install_updater_service
# ---- Step 7: Start Caddy ----
step "Starting web server"
start_caddy
print_success "$(elapsed "$start_time")"
}
main "$@"