feat(deploy): installer and updater for a fresh server
install.sh sets up a bare Debian/Ubuntu server end to end: Docker, git, WireGuard, clone, generated secrets, stack, migrations, hub keypair, the Owner account and a closed firewall. Re-runnable: it keeps an existing .env and never regenerates the hub key, which would disconnect every onboarded host. update.sh pulls and applies. Migrations run before the new containers take traffic, the image is rebuilt only when its definition changed, and the queue workers are restarted — they are long-running processes that otherwise keep the old classes in memory, which cost us an hour during development. clupilot:create-admin creates or promotes an Owner, so re-running the installer fixes a lost role instead of failing on a taken address. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
e2b4cdbac4
commit
834abcec40
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates (or promotes) an operator with the Owner role — the account the
|
||||||
|
* installer sets up so a fresh server is usable immediately.
|
||||||
|
*
|
||||||
|
* Promotion is deliberate: running this twice with the same address should fix
|
||||||
|
* a lost role, not fail with "email already taken".
|
||||||
|
*/
|
||||||
|
class CreateAdmin extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'clupilot:create-admin
|
||||||
|
{--email= : Login address}
|
||||||
|
{--name= : Display name}
|
||||||
|
{--password= : Leave empty to be prompted}';
|
||||||
|
|
||||||
|
protected $description = 'Create or promote an operator account with the Owner role';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$email = $this->option('email') ?: $this->ask('Email');
|
||||||
|
$name = $this->option('name') ?: $this->ask('Name', 'Administrator');
|
||||||
|
$password = $this->option('password') ?: $this->secret('Password');
|
||||||
|
|
||||||
|
$validator = Validator::make(
|
||||||
|
['email' => $email, 'name' => $name, 'password' => $password],
|
||||||
|
[
|
||||||
|
'email' => 'required|email|max:255',
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'password' => ['required', Password::min(12)],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
foreach ($validator->errors()->all() as $error) {
|
||||||
|
$this->error($error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator address that also belongs to a customer would block that
|
||||||
|
// customer from ever getting a portal login.
|
||||||
|
if (Customer::query()->where('email', $email)->exists()) {
|
||||||
|
$this->error('That address already belongs to a customer.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->firstOrNew(['email' => $email]);
|
||||||
|
$existed = $user->exists;
|
||||||
|
|
||||||
|
$user->fill([
|
||||||
|
'name' => $name,
|
||||||
|
'password' => Hash::make($password),
|
||||||
|
'is_admin' => true,
|
||||||
|
'email_verified_at' => $user->email_verified_at ?? now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$user->syncRoles(['Owner']);
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
|
||||||
|
$this->info($existed
|
||||||
|
? "Existing account {$email} promoted to Owner and password reset."
|
||||||
|
: "Owner account {$email} created.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# CluPilot — first-run installer for a fresh Debian/Ubuntu server (netcup VPS).
|
||||||
|
#
|
||||||
|
# Installs Docker, git and WireGuard, clones the repository, generates every
|
||||||
|
# secret the application needs, brings the stack up, runs the migrations,
|
||||||
|
# creates the WireGuard hub and your Owner account.
|
||||||
|
#
|
||||||
|
# Usage (interactive):
|
||||||
|
# curl -fsSL <raw-url>/deploy/install.sh -o install.sh && bash install.sh
|
||||||
|
#
|
||||||
|
# Usage (unattended — every prompt can be answered by an environment variable):
|
||||||
|
# GIT_TOKEN=… ADMIN_EMAIL=… ADMIN_PASSWORD=… APP_DOMAIN=app.clupilot.com \
|
||||||
|
# ADMIN_DOMAIN=admin.clupilot.com bash install.sh
|
||||||
|
#
|
||||||
|
# Safe to re-run: it never overwrites an existing .env and never regenerates
|
||||||
|
# the WireGuard hub key (that would silently disconnect every host).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_URL="${REPO_URL:-https://git.bave.dev/boban/CluPilotCloud.git}"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/clupilot}"
|
||||||
|
BRANCH="${BRANCH:-main}"
|
||||||
|
|
||||||
|
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
|
||||||
|
die() { printf '\033[1;31m ✗\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
ask() { # ask VAR "Prompt" [default] — skipped when VAR is already set
|
||||||
|
local var="$1" prompt="$2" default="${3:-}" answer
|
||||||
|
if [[ -n "${!var:-}" ]]; then return; fi
|
||||||
|
if [[ ! -t 0 ]]; then die "$var is not set and there is no terminal to ask on."; fi
|
||||||
|
read -r -p "$prompt${default:+ [$default]}: " answer
|
||||||
|
printf -v "$var" '%s' "${answer:-$default}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_secret() {
|
||||||
|
local var="$1" prompt="$2" answer
|
||||||
|
if [[ -n "${!var:-}" ]]; then return; fi
|
||||||
|
if [[ ! -t 0 ]]; then die "$var is not set and there is no terminal to ask on."; fi
|
||||||
|
read -r -s -p "$prompt: " answer; echo
|
||||||
|
printf -v "$var" '%s' "$answer"
|
||||||
|
}
|
||||||
|
|
||||||
|
rand_b64() { head -c "${1:-32}" /dev/urandom | base64 -w0; }
|
||||||
|
rand_hex() { head -c "${1:-24}" /dev/urandom | od -An -tx1 | tr -d ' \n'; }
|
||||||
|
|
||||||
|
# ── 0. Preflight ─────────────────────────────────────────────────────────────
|
||||||
|
[[ $EUID -eq 0 ]] || die "Please run as root (sudo bash install.sh)."
|
||||||
|
[[ -f /etc/os-release ]] || die "Unsupported system: no /etc/os-release."
|
||||||
|
. /etc/os-release
|
||||||
|
case "${ID}${ID_LIKE:-}" in
|
||||||
|
*debian*|*ubuntu*) ;;
|
||||||
|
*) die "Only Debian and Ubuntu are supported, found: ${PRETTY_NAME}." ;;
|
||||||
|
esac
|
||||||
|
[[ "$(uname -m)" == "x86_64" ]] || die "Only x86_64 is supported, found $(uname -m)."
|
||||||
|
|
||||||
|
log "CluPilot installer on ${PRETTY_NAME}"
|
||||||
|
|
||||||
|
# ── 1. Answers we need before doing anything ─────────────────────────────────
|
||||||
|
ask APP_DOMAIN "Public domain for the customer portal" "app.clupilot.com"
|
||||||
|
ask WWW_DOMAIN "Domain for the marketing site" "www.clupilot.com"
|
||||||
|
ask ADMIN_DOMAIN "Private domain for the operator console" "admin.clupilot.com"
|
||||||
|
ask WS_DOMAIN "Domain for the websocket server" "ws.clupilot.com"
|
||||||
|
ask ADMIN_EMAIL "Your login address"
|
||||||
|
ask ADMIN_NAME "Your display name" "Administrator"
|
||||||
|
ask_secret ADMIN_PASSWORD "Your password (min. 12 characters)"
|
||||||
|
[[ ${#ADMIN_PASSWORD} -ge 12 ]] || die "The password must be at least 12 characters."
|
||||||
|
|
||||||
|
# The repository is private; a Gitea token with read access is enough.
|
||||||
|
if [[ ! -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
ask_secret GIT_TOKEN "Gitea access token (read access to the repository)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Packages ──────────────────────────────────────────────────────────────
|
||||||
|
log "Installing packages"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq ca-certificates curl git gnupg wireguard-tools ufw >/dev/null
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null; then
|
||||||
|
log "Installing Docker"
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL "https://download.docker.com/linux/${ID}/gpg" -o /etc/apt/keyrings/docker.asc
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null
|
||||||
|
systemctl enable --now docker >/dev/null
|
||||||
|
fi
|
||||||
|
docker compose version >/dev/null || die "docker compose plugin is missing."
|
||||||
|
|
||||||
|
# WireGuard needs the module loadable before a container can create wg0.
|
||||||
|
modprobe wireguard 2>/dev/null || warn "Could not load the wireguard module — check the kernel."
|
||||||
|
echo wireguard > /etc/modules-load.d/wireguard.conf
|
||||||
|
|
||||||
|
# ── 3. Source ────────────────────────────────────────────────────────────────
|
||||||
|
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
log "Updating existing checkout in $INSTALL_DIR"
|
||||||
|
git -C "$INSTALL_DIR" fetch --quiet origin "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" checkout --quiet "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" pull --quiet --ff-only origin "$BRANCH"
|
||||||
|
else
|
||||||
|
log "Cloning into $INSTALL_DIR"
|
||||||
|
# The token is passed for this one command and never written to disk or to
|
||||||
|
# the git remote — a later `git pull` will ask, or use a deploy key.
|
||||||
|
auth_url="${REPO_URL/https:\/\//https://oauth2:${GIT_TOKEN}@}"
|
||||||
|
git clone --quiet --branch "$BRANCH" "$auth_url" "$INSTALL_DIR"
|
||||||
|
git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL"
|
||||||
|
fi
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# ── 4. Configuration ─────────────────────────────────────────────────────────
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
log "Keeping the existing .env (delete it to start over)"
|
||||||
|
else
|
||||||
|
log "Writing .env with freshly generated secrets"
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
set_env() { # set_env KEY VALUE — replaces the line, appends if absent
|
||||||
|
local key="$1" value="$2"
|
||||||
|
if grep -qE "^${key}=" .env; then
|
||||||
|
# `|` as the delimiter: values contain / and + from base64.
|
||||||
|
sed -i "s|^${key}=.*|${key}=${value}|" .env
|
||||||
|
else
|
||||||
|
printf '%s=%s\n' "$key" "$value" >> .env
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
set_env APP_ENV production
|
||||||
|
set_env APP_DEBUG false
|
||||||
|
set_env APP_URL "https://${APP_DOMAIN}"
|
||||||
|
set_env APP_KEY "base64:$(rand_b64 32)"
|
||||||
|
|
||||||
|
set_env DB_PASSWORD "$(rand_hex 24)"
|
||||||
|
set_env VPN_CONFIG_KEY "$(rand_b64 32)"
|
||||||
|
|
||||||
|
set_env REVERB_APP_ID "clupilot"
|
||||||
|
set_env REVERB_APP_KEY "$(rand_hex 16)"
|
||||||
|
set_env REVERB_APP_SECRET "$(rand_hex 24)"
|
||||||
|
set_env VITE_REVERB_APP_KEY "$(grep '^REVERB_APP_KEY=' .env | cut -d= -f2-)"
|
||||||
|
set_env VITE_REVERB_HOST "$WS_DOMAIN"
|
||||||
|
set_env VITE_REVERB_PORT 443
|
||||||
|
set_env VITE_REVERB_SCHEME https
|
||||||
|
|
||||||
|
# The console answers only on its own hostname; everything else 404s.
|
||||||
|
set_env ADMIN_HOSTS "${ADMIN_DOMAIN},127.0.0.1,localhost"
|
||||||
|
set_env TRUSTED_RANGES "10.66.0.0/24,127.0.0.1"
|
||||||
|
|
||||||
|
set_env CLUPILOT_WG_ENDPOINT "$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo 'SET-ME'):51820"
|
||||||
|
|
||||||
|
set_env HOST_UID 1000
|
||||||
|
set_env HOST_GID 1000
|
||||||
|
set_env VITE_AUTOSTART false
|
||||||
|
|
||||||
|
chmod 600 .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 5. Stack ─────────────────────────────────────────────────────────────────
|
||||||
|
log "Building and starting the containers (this takes a few minutes)"
|
||||||
|
docker compose build --quiet app
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
log "Waiting for the application container"
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
docker compose exec -T app php -v >/dev/null 2>&1 && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Running migrations"
|
||||||
|
docker compose exec -T app php artisan migrate --force
|
||||||
|
|
||||||
|
# ── 6. WireGuard hub ─────────────────────────────────────────────────────────
|
||||||
|
if docker compose exec -T queue-provisioning test -f /etc/wireguard/wg0.conf 2>/dev/null; then
|
||||||
|
log "WireGuard hub already configured — leaving it alone"
|
||||||
|
else
|
||||||
|
log "Setting up the WireGuard hub"
|
||||||
|
docker compose exec -T queue-provisioning sh -c '
|
||||||
|
set -e; umask 077; mkdir -p /etc/wireguard
|
||||||
|
wg genkey > /etc/wireguard/hub.key
|
||||||
|
cat > /etc/wireguard/wg0.conf <<EOF
|
||||||
|
# CluPilot management hub. Peers are managed by the application
|
||||||
|
# (wg set + wg-quick save), so this file is rewritten — keep edits minimal.
|
||||||
|
[Interface]
|
||||||
|
Address = 10.66.0.1/24
|
||||||
|
ListenPort = 51820
|
||||||
|
PrivateKey = $(cat /etc/wireguard/hub.key)
|
||||||
|
EOF
|
||||||
|
chmod 600 /etc/wireguard/wg0.conf /etc/wireguard/hub.key
|
||||||
|
wg-quick up wg0 >/dev/null 2>&1 || true'
|
||||||
|
fi
|
||||||
|
|
||||||
|
HUB_PUBKEY="$(docker compose exec -T queue-provisioning sh -c 'wg pubkey < /etc/wireguard/hub.key' | tr -d '\r\n')"
|
||||||
|
sed -i "s|^CLUPILOT_WG_HUB_PUBKEY=.*|CLUPILOT_WG_HUB_PUBKEY=${HUB_PUBKEY}|" .env
|
||||||
|
docker compose exec -T app php artisan config:clear >/dev/null
|
||||||
|
|
||||||
|
# ── 7. Your account ──────────────────────────────────────────────────────────
|
||||||
|
log "Creating your Owner account"
|
||||||
|
docker compose exec -T app php artisan clupilot:create-admin \
|
||||||
|
--email="$ADMIN_EMAIL" --name="$ADMIN_NAME" --password="$ADMIN_PASSWORD"
|
||||||
|
|
||||||
|
# ── 8. Firewall ──────────────────────────────────────────────────────────────
|
||||||
|
# Only what has to be reachable: SSH, the web ports, and the tunnel. The console
|
||||||
|
# is additionally restricted to ADMIN_HOSTS inside the application.
|
||||||
|
log "Configuring the firewall"
|
||||||
|
ufw --force reset >/dev/null
|
||||||
|
ufw default deny incoming >/dev/null
|
||||||
|
ufw default allow outgoing >/dev/null
|
||||||
|
ufw allow 22/tcp >/dev/null
|
||||||
|
ufw allow 80/tcp >/dev/null
|
||||||
|
ufw allow 443/tcp >/dev/null
|
||||||
|
ufw allow 51820/udp >/dev/null
|
||||||
|
ufw --force enable >/dev/null
|
||||||
|
|
||||||
|
# ── 9. Done ──────────────────────────────────────────────────────────────────
|
||||||
|
PUBLIC_IP="$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo '<server ip>')"
|
||||||
|
cat <<SUMMARY
|
||||||
|
|
||||||
|
$(printf '\033[1;32m✓ CluPilot is installed.\033[0m')
|
||||||
|
|
||||||
|
Directory $INSTALL_DIR
|
||||||
|
Console https://${ADMIN_DOMAIN}/admin (private — VPN only)
|
||||||
|
Portal https://${APP_DOMAIN}
|
||||||
|
WireGuard hub ${PUBLIC_IP}:51820
|
||||||
|
Hub public key ${HUB_PUBKEY}
|
||||||
|
|
||||||
|
Still to do, in this order:
|
||||||
|
|
||||||
|
1. Point the DNS records at ${PUBLIC_IP} and put TLS in front (Zoraxy,
|
||||||
|
Caddy or nginx). ${ADMIN_DOMAIN} must NOT be publicly resolvable.
|
||||||
|
2. Enter STRIPE_WEBHOOK_SECRET in $INSTALL_DIR/.env — Stripe dashboard →
|
||||||
|
Developers → Webhooks → https://${APP_DOMAIN}/webhooks/stripe, events
|
||||||
|
checkout.session.completed and checkout.session.async_payment_succeeded.
|
||||||
|
3. Sign in and switch the website to hidden under Settings until you are
|
||||||
|
ready to be found (Settings → Website visibility).
|
||||||
|
|
||||||
|
Updating later:
|
||||||
|
|
||||||
|
cd $INSTALL_DIR && bash deploy/update.sh
|
||||||
|
|
||||||
|
SUMMARY
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# CluPilot — pull the latest code and apply it.
|
||||||
|
#
|
||||||
|
# Order matters: migrations run BEFORE the new containers take traffic, because
|
||||||
|
# code that expects a column the database does not have yet answers with 500s.
|
||||||
|
# The queue workers are restarted at the end — they are long-running processes
|
||||||
|
# and keep the old classes in memory otherwise.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
BRANCH="${BRANCH:-main}"
|
||||||
|
|
||||||
|
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
|
log "Fetching $BRANCH"
|
||||||
|
git fetch --quiet origin "$BRANCH"
|
||||||
|
|
||||||
|
before="$(git rev-parse HEAD)"
|
||||||
|
git merge --quiet --ff-only "origin/$BRANCH"
|
||||||
|
after="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
if [[ "$before" == "$after" ]]; then
|
||||||
|
log "Already up to date ($(git rev-parse --short HEAD))"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Updating $(git rev-parse --short "$before") → $(git rev-parse --short "$after")"
|
||||||
|
git --no-pager log --oneline "$before..$after" | sed 's/^/ /'
|
||||||
|
|
||||||
|
# Rebuilt only when the image definition actually changed — a rebuild is
|
||||||
|
# minutes, a no-op check is seconds.
|
||||||
|
if ! git diff --quiet "$before" "$after" -- docker/ composer.json composer.lock package.json package-lock.json; then
|
||||||
|
log "Rebuilding the image"
|
||||||
|
docker compose build --quiet app
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Applying migrations"
|
||||||
|
docker compose exec -T app php artisan migrate --force
|
||||||
|
|
||||||
|
log "Rebuilding assets"
|
||||||
|
docker compose exec -T app npm run build
|
||||||
|
|
||||||
|
log "Restarting services"
|
||||||
|
docker compose up -d
|
||||||
|
# Workers hold their PHP classes for the life of the process; without this they
|
||||||
|
# keep running the code from before the update.
|
||||||
|
docker compose restart queue queue-provisioning scheduler reverb
|
||||||
|
|
||||||
|
docker compose exec -T app php artisan config:clear >/dev/null
|
||||||
|
docker compose exec -T app php artisan view:clear >/dev/null
|
||||||
|
|
||||||
|
log "Done — now on $(git rev-parse --short HEAD)"
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Deploying CluPilot on a fresh server
|
||||||
|
|
||||||
|
Developed locally, deployed by pulling. Two scripts do the work:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# once, on a fresh Debian/Ubuntu server as root
|
||||||
|
bash deploy/install.sh
|
||||||
|
|
||||||
|
# afterwards, to bring it up to date
|
||||||
|
cd /opt/clupilot && bash deploy/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the installer does
|
||||||
|
|
||||||
|
Docker, git and `wireguard-tools`; clone into `/opt/clupilot`; generate every
|
||||||
|
secret (`APP_KEY`, `VPN_CONFIG_KEY`, database password, Reverb keys); start the
|
||||||
|
stack; migrate; create the WireGuard hub and print its public key; create your
|
||||||
|
Owner account; close the firewall down to 22, 80, 443 and 51820/udp.
|
||||||
|
|
||||||
|
It is safe to re-run: an existing `.env` is kept and the hub key is never
|
||||||
|
regenerated — doing so would silently disconnect every onboarded host.
|
||||||
|
|
||||||
|
Every prompt has an environment variable, so it can also run unattended:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GIT_TOKEN=… ADMIN_EMAIL=… ADMIN_PASSWORD=… APP_DOMAIN=app.clupilot.com \
|
||||||
|
ADMIN_DOMAIN=admin.clupilot.com bash deploy/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Order of the update
|
||||||
|
|
||||||
|
Migrations run **before** the new containers take traffic: code that expects a
|
||||||
|
column the database does not have yet answers with 500s. The image is rebuilt
|
||||||
|
only when `docker/`, composer or npm manifests actually changed. Queue workers
|
||||||
|
are restarted at the end — they are long-running PHP processes and keep the old
|
||||||
|
classes in memory otherwise. That one bit us during development: a fixed job
|
||||||
|
kept failing until the worker was restarted.
|
||||||
|
|
||||||
|
## What the installer deliberately leaves to you
|
||||||
|
|
||||||
|
- **DNS and TLS.** Put a reverse proxy in front (Zoraxy, Caddy, nginx). The
|
||||||
|
console hostname must not be publicly resolvable: `/admin` answers only on
|
||||||
|
the hostnames in `ADMIN_HOSTS` and 404s everywhere else, but keeping it out
|
||||||
|
of public DNS is the layer above that.
|
||||||
|
- **`STRIPE_WEBHOOK_SECRET`.** Stripe dashboard → Developers → Webhooks →
|
||||||
|
`https://<app-domain>/webhooks/stripe`, events `checkout.session.completed`
|
||||||
|
and `checkout.session.async_payment_succeeded`. The secret starts with
|
||||||
|
`whsec_` and differs between test and live mode.
|
||||||
|
- **Backups.** At minimum the MariaDB volume and `/etc/wireguard` in the
|
||||||
|
`wireguard` volume — losing the hub key means re-peering every host.
|
||||||
|
|
||||||
|
## Hiding the site while you build
|
||||||
|
|
||||||
|
Settings → Website visibility. Outsiders then get a placeholder (503,
|
||||||
|
`noindex`), while anyone on the management VPN and any signed-in operator keeps
|
||||||
|
seeing the real thing. The console stays reachable either way, so the switch can
|
||||||
|
always be flipped back.
|
||||||
Loading…
Reference in New Issue