DigitalOcean is the fastest way to get a production Track Arrow server. The official startup script installs Docker, MySQL, Redis, ClickHouse, the API, and the admin panel on first boot.
Do not use 1 GB or 2 GB Droplets. ClickHouse and MySQL will run out of memory.
YOUR_LICENSE_KEY and YOUR_LICENSE_TOKEN with values from trackarrow.io → Licenses. Click Generate License Token for LICENSE_TOKEN (starts with ta_). Optionally set DOMAIN (A record → Droplet IP) and LETSENCRYPT_EMAIL for automatic HTTPS.If you set DOMAIN, point the A record to the Droplet IP before or immediately after create. Caddy will request a free Let’s Encrypt certificate.
Copy this script and paste it into DigitalOcean Startup scripts. Then replace YOUR_LICENSE_KEY and YOUR_LICENSE_TOKEN. Those values come from trackarrow.io → Licenses (buy or renew a plan, then Generate License Token). You do not need Track Arrow installed first.
#!/bin/bash
# ─────────────────────────────────────────────────────────────────────────────
# Track Arrow installer (Ubuntu 22.04 / 24.04, Docker images from GHCR)
#
# Alternative method (SSH as root). Requires an active subscription:
# export LICENSE_KEY="XXXX-XXXX-XXXX-XXXX"
# export LICENSE_TOKEN="ta_xxxxxxxxxxxxxxxxx" # Licenses → Generate License Token
# export LICENSE_CHANNEL="beta" # or stable (default)
# export DOMAIN="tracker.example.com" # optional HTTPS
# curl -fsSL https://docs.trackarrow.io/releases/install.sh | bash
#
# DigitalOcean: paste this file into Droplet → Additional Options → Startup
# scripts, then set LICENSE_KEY and LICENSE_TOKEN below.
#
# Recommended VPS: 2 vCPU · 4 GB RAM · ~120 GB SSD
# Log: /var/log/trackarrow-install.log
# Credentials: /root/trackarrow-credentials.txt
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
exec > >(tee -a /var/log/trackarrow-install.log) 2>&1
trap 'echo "[trackarrow] ERROR at line $LINENO (exit $?) — see /var/log/trackarrow-install.log"' ERR
echo "[trackarrow] Starting install at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# ── REQUIRED — license key from trackarrow.io (active subscription) ──────────
# Environment variables win (curl | bash). Otherwise edit the default here.
LICENSE_KEY="${LICENSE_KEY:-YOUR_LICENSE_KEY}"
LICENSE_TOKEN="${LICENSE_TOKEN:-YOUR_LICENSE_TOKEN}"
LICENSE_API_URL="${LICENSE_API_URL:-https://account.trackarrow.io/api/v1/verify}"
INSTALL_DIR="${INSTALL_DIR:-/opt/trackarrow}"
LICENSE_CHANNEL="${LICENSE_CHANNEL:-${CHANNEL:-stable}}"
UPDATES_BASE="${UPDATES_BASE:-https://docs.trackarrow.io}"
RUNTIME_URL="${RUNTIME_URL:-}"
# Leave RUNTIME_URL empty to use $UPDATES_BASE/releases/runtime.tar.gz
# ── Domain + HTTPS (optional but recommended) ────────────────────────────────
# Point the domain A record to this server IP BEFORE install (or right after).
# Caddy will obtain a free Let's Encrypt certificate automatically.
DOMAIN="${DOMAIN:-}"
# Example: DOMAIN="tracker.example.com"
LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL:-}"
# Example: LETSENCRYPT_EMAIL="admin@example.com" (defaults to admin@$DOMAIN)
export DEBIAN_FRONTEND=noninteractive
# Normalize domain (strip scheme / trailing slash)
DOMAIN="$(echo "${DOMAIN:-}" | sed -E "s#^https?://##; s#/.*##; s/^[[:space:]]+//; s/[[:space:]]+$//" | tr "[:upper:]" "[:lower:]")"
if [ -n "$DOMAIN" ] && [ -z "${LETSENCRYPT_EMAIL:-}" ]; then
LETSENCRYPT_EMAIL="admin@${DOMAIN}"
fi
if [ -n "$DOMAIN" ]; then
FRONTEND_HOST_PORT=3030
echo "[trackarrow] Domain mode: https://${DOMAIN} (Caddy TLS → frontend :3030 + API :4000)"
else
FRONTEND_HOST_PORT=80
echo "[trackarrow] No DOMAIN set — app will be on http://DROPLET_IP/"
fi
# ── RAM check ────────────────────────────────────────────────────────────────
TOTAL_MB=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)
echo "[trackarrow] Detected RAM: ${TOTAL_MB} MB"
if [ "$TOTAL_MB" -lt 1800 ]; then
echo "[trackarrow] FATAL: need at least ~2 GB RAM (4 GB strongly recommended)."
echo "[trackarrow] Recreate Droplet as Basic → 2 vCPU / 4 GB / 120 GB (~$32/mo)."
exit 1
fi
if [ "$TOTAL_MB" -lt 3500 ]; then
echo "[trackarrow] WARNING: <4 GB RAM - ClickHouse may OOM. Adding 4 GB swap."
fi
echo "[trackarrow] Waiting for apt locks..."
apt-get update -y || true
apt-get install -y ca-certificates curl gnupg lsb-release ufw openssl psmisc dnsutils tar python3 || true
for i in $(seq 1 60); do
if ! fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
&& ! fuser /var/lib/apt/lists/lock >/dev/null 2>&1; then
break
fi
sleep 5
done
echo "[trackarrow] Installing base packages..."
apt-get update -y
apt-get install -y ca-certificates curl gnupg lsb-release ufw openssl psmisc dnsutils tar python3
# ── Swap (helps 2 GB Droplets survive ClickHouse boot) ───────────────────────
if [ ! -f /swapfile ]; then
echo "[trackarrow] Creating 4G swapfile..."
fallocate -l 4G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=4096
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
grep -q "/swapfile" /etc/fstab || echo "/swapfile none swap sw 0 0" >> /etc/fstab
fi
# ── Docker Engine + Compose plugin ───────────────────────────────────────────
if ! command -v docker >/dev/null 2>&1; then
echo "[trackarrow] Installing Docker..."
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
fi
echo "[trackarrow] Docker: $(docker --version)"
echo "[trackarrow] Compose: $(docker compose version)"
# ── License: active subscription required, pull token comes from verify ────────
LICENSE_KEY="$(echo "${LICENSE_KEY:-}" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:].')"
LICENSE_KEY="$(echo "$LICENSE_KEY" | sed -E 's/[^A-Z0-9]//g')"
if [ "${#LICENSE_KEY}" -eq 16 ]; then
LICENSE_KEY="$(echo "$LICENSE_KEY" | sed -E 's/(.{4})(.{4})(.{4})(.{4})/\1-\2-\3-\4/')"
fi
if [ -z "$LICENSE_KEY" ] || [ "$LICENSE_KEY" = "YOUR-LICENS-EKEY" ] || [ "$LICENSE_KEY" = "YOURLICENSEKEY" ] || [ "${#LICENSE_KEY}" -lt 19 ]; then
echo "[trackarrow] FATAL: set LICENSE_KEY to your key from trackarrow.io (XXXX-XXXX-XXXX-XXXX)."
echo "[trackarrow] Install only runs with an active paid subscription."
exit 1
fi
PUBLIC_IP="$(curl -4 -fsS --max-time 10 https://ifconfig.me || curl -4 -fsS --max-time 10 https://api.ipify.org || hostname -I | awk '{print $1}' || true)"
if [ -z "$PUBLIC_IP" ]; then
echo "[trackarrow] FATAL: could not detect this server's public IP."
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "[trackarrow] FATAL: python3 is required to verify the license."
exit 1
fi
echo "[trackarrow] Public IP: $PUBLIC_IP"
echo "[trackarrow] Checking subscription with License Manager..."
LICENSE_TOKEN="$(echo "${LICENSE_TOKEN:-}" | tr -d '[:space:]')"
GHCR_TOKEN=""
if [ -n "$LICENSE_TOKEN" ] && [ "$LICENSE_TOKEN" != "YOUR_LICENSE_TOKEN" ] && echo "$LICENSE_TOKEN" | grep -qi '^ta_'; then
DECODED="$(
LICENSE_TOKEN="$LICENSE_TOKEN" python3 - <<'PY'
import base64, os, sys
raw = os.environ["LICENSE_TOKEN"].strip()
if not raw.lower().startswith("ta_"):
sys.exit(1)
b64 = raw[3:].replace("-", "+").replace("_", "/")
pad = "=" * ((4 - len(b64) % 4) % 4)
try:
token = base64.b64decode(b64 + pad).decode("utf-8").strip()
except Exception:
sys.exit(1)
if not token:
sys.exit(1)
sys.stdout.write(token)
PY
)" || {
echo "[trackarrow] FATAL: LICENSE_TOKEN is invalid. Copy it from trackarrow.io → Licenses → Generate License Token (starts with ta_)."
exit 1
}
case "$DECODED" in
ghp_*|ghs_*|github_pat_*) GHCR_TOKEN="$DECODED" ;;
esac
fi
RESP_FILE="$(mktemp)"
chmod 600 "$RESP_FILE"
VERIFY_JSON="$(
LICENSE_KEY="$LICENSE_KEY" PUBLIC_IP="$PUBLIC_IP" LICENSE_TOKEN="$LICENSE_TOKEN" python3 - <<'PY'
import json, os
payload = {"license_key": os.environ["LICENSE_KEY"], "ip": os.environ["PUBLIC_IP"]}
tok = os.environ.get("LICENSE_TOKEN") or ""
if tok and tok != "YOUR_LICENSE_TOKEN":
payload["license_token"] = tok
print(json.dumps(payload))
PY
)"
HTTP_CODE="$(
curl -sS --max-time 20 -o "$RESP_FILE" -w '%{http_code}' \
-X POST "$LICENSE_API_URL" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: TrackArrow-Installer/1.0' \
--data "$VERIFY_JSON"
)" || {
rm -f "$RESP_FILE"
echo "[trackarrow] FATAL: could not reach License Manager at $LICENSE_API_URL"
exit 1
}
VERIFY_TOKEN="$(
RESP_FILE="$RESP_FILE" HTTP_CODE="$HTTP_CODE" python3 - <<'PY'
import json, os, sys, base64
path = os.environ["RESP_FILE"]
code = str(os.environ.get("HTTP_CODE") or "")
raw = open(path, encoding="utf-8", errors="replace").read()
if "error code: 1010" in raw or "Just a moment" in raw:
print("cloudflare_blocked", file=sys.stderr)
sys.exit(1)
try:
data = json.loads(raw)
except Exception:
print(f"license_http_{code or 'error'}", file=sys.stderr)
sys.exit(1)
if not data.get("valid"):
print(str(data.get("reason") or f"license_http_{code}"), file=sys.stderr)
sys.exit(2)
raw_tok = str(
data.get("install_token")
or data.get("registry_token")
or data.get("ghcr_token")
or data.get("pull_token")
or ""
).strip()
if not raw_tok.lower().startswith("ta_"):
sys.exit(0)
b64 = raw_tok[3:].replace("-", "+").replace("_", "/")
pad = "=" * ((4 - len(b64) % 4) % 4)
try:
token = base64.b64decode(b64 + pad).decode("utf-8").strip()
except Exception:
sys.exit(0)
if token:
sys.stdout.write(token)
PY
)" || {
REASON="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("reason") or "")' "$RESP_FILE" 2>/dev/null || true)"
rm -f "$RESP_FILE"
echo "[trackarrow] FATAL: license verify failed (${REASON:-need an active subscription})."
echo "[trackarrow] Buy or renew at https://trackarrow.io then retry with LICENSE_KEY and LICENSE_TOKEN."
exit 1
}
rm -f "$RESP_FILE"
if [ -z "${GHCR_TOKEN:-}" ]; then
GHCR_TOKEN="$VERIFY_TOKEN"
fi
if [ -z "${GHCR_TOKEN:-}" ]; then
echo "[trackarrow] FATAL: set LICENSE_TOKEN from trackarrow.io → Licenses → Generate License Token (starts with ta_)."
exit 1
fi
if [ -z "${LICENSE_TOKEN:-}" ] || [ "$LICENSE_TOKEN" = "YOUR_LICENSE_TOKEN" ]; then
LICENSE_TOKEN=""
fi
# ── Download pre-built runtime (compose + configs), never clone source ────────
mkdir -p "$(dirname "$INSTALL_DIR")"
LICENSE_CHANNEL="$(echo "${LICENSE_CHANNEL:-stable}" | tr "[:upper:]" "[:lower:]")"
if [ "$LICENSE_CHANNEL" != "beta" ]; then LICENSE_CHANNEL=stable; fi
UPDATES_BASE="$(echo "${UPDATES_BASE:-https://docs.trackarrow.io}" | sed "s#/\$##")"
GITHUB_REPO="${GITHUB_REPO:-trackarrow/tracker}"
FEED_VERSION=""
if FEED_JSON="$(curl -fsSL "${UPDATES_BASE}/updates/${LICENSE_CHANNEL}.json" 2>/dev/null)"; then
FEED_VERSION="$(printf "%s" "$FEED_JSON" | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -n1)"
fi
if [ -z "$FEED_VERSION" ] || [ "$FEED_VERSION" = "null" ]; then
FEED_VERSION="$LICENSE_CHANNEL"
fi
echo "[trackarrow] Channel $LICENSE_CHANNEL → image tag $FEED_VERSION"
if [ -z "${RUNTIME_URL:-}" ]; then
RUNTIME_URL="${UPDATES_BASE}/releases/runtime.tar.gz"
fi
echo "[trackarrow] Downloading runtime bundle from $RUNTIME_URL"
mkdir -p "$INSTALL_DIR"
if ! curl -fsSL "$RUNTIME_URL" -o /tmp/trackarrow-runtime.tar.gz; then
echo "[trackarrow] Docs bundle not found — trying GitHub Release v${FEED_VERSION}..."
if ! GHCR_TOKEN="$GHCR_TOKEN" GITHUB_REPO="$GITHUB_REPO" FEED_VERSION="$FEED_VERSION" LICENSE_CHANNEL="$LICENSE_CHANNEL" python3 - <<'PY'
import json, os, sys, urllib.request
repo = os.environ["GITHUB_REPO"].strip()
version = os.environ["FEED_VERSION"].strip().lstrip("v")
channel = os.environ.get("LICENSE_CHANNEL", "stable").strip().lower()
token = os.environ["GHCR_TOKEN"].strip()
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"User-Agent": "TrackArrow-Installer/1.0",
"X-GitHub-Api-Version": "2022-11-28",
}
def get(url):
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=20) as res:
return json.load(res)
rel = None
try:
rel = get(f"https://api.github.com/repos/{repo}/releases/tags/v{version}")
except Exception:
rel = None
if rel is None:
try:
releases = get(f"https://api.github.com/repos/{repo}/releases?per_page=20")
except Exception as e:
print(f"github_release:{e}", file=sys.stderr)
sys.exit(1)
want_pre = channel == "beta"
for item in releases or []:
if bool(item.get("prerelease")) == want_pre:
rel = item
break
if rel is None and releases:
rel = releases[0]
if not rel:
print("github_release_not_found", file=sys.stderr)
sys.exit(1)
asset = next((a for a in rel.get("assets") or [] if a.get("name") == "runtime.tar.gz"), None)
if not asset:
print("github_release_missing_runtime", file=sys.stderr)
sys.exit(1)
req = urllib.request.Request(
asset["url"],
headers={
"Accept": "application/octet-stream",
"Authorization": f"Bearer {token}",
"User-Agent": "TrackArrow-Installer/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as res:
open("/tmp/trackarrow-runtime.tar.gz", "wb").write(res.read())
except Exception as e:
print(f"github_asset:{e}", file=sys.stderr)
sys.exit(1)
print(f"[trackarrow] GitHub Release {rel.get('tag_name')}", file=sys.stderr)
PY
then
echo "[trackarrow] FATAL: could not download runtime.tar.gz from docs or GitHub Release."
exit 1
fi
fi
if [ ! -s /tmp/trackarrow-runtime.tar.gz ]; then
echo "[trackarrow] FATAL: could not download runtime.tar.gz from docs or GitHub Release."
exit 1
fi
tar -xzf /tmp/trackarrow-runtime.tar.gz -C "$INSTALL_DIR"
rm -f /tmp/trackarrow-runtime.tar.gz
if [ ! -f "$INSTALL_DIR/docker-compose.yml" ]; then
echo "[trackarrow] FATAL: runtime bundle is missing docker-compose.yml"
exit 1
fi
chmod +x "$INSTALL_DIR/scripts/apply-update.sh" "$INSTALL_DIR/scripts/docker-login-from-license.sh" 2>/dev/null || true
GHCR_LOGIN_USER=trackarrow
case "$GHCR_TOKEN" in
ghs_*|gho_*) GHCR_LOGIN_USER=x-access-token ;;
github_pat_*)
echo "[trackarrow] FATAL: GHCR does not accept fine-grained tokens (github_pat_)."
echo "[trackarrow] License Manager GITHUB_PACKAGES_TOKEN must be a classic PAT (ghp_) with read:packages, created by GitHub user trackarrow."
exit 1
;;
*)
GHCR_LOGIN_USER="${GHCR_USERNAME:-trackarrow}"
if [ "$GHCR_LOGIN_USER" = "x-access-token" ]; then
GHCR_LOGIN_USER=trackarrow
fi
;;
esac
echo "[trackarrow] Logging in to ghcr.io as ${GHCR_LOGIN_USER}"
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_LOGIN_USER" --password-stdin
unset GHCR_TOKEN
cd "$INSTALL_DIR" || { echo "[trackarrow] FATAL: missing $INSTALL_DIR"; exit 1; }
# ── Generate .env + credentials EARLY (before compose can fail) ──────────────
MYSQL_ROOT_PASSWORD="$(openssl rand -base64 24 | tr -d "/+=" | head -c 24)"
MYSQL_PASSWORD="$(openssl rand -base64 24 | tr -d "/+=" | head -c 24)"
REDIS_PASSWORD="$(openssl rand -base64 24 | tr -d "/+=" | head -c 24)"
CH_PASSWORD="$(openssl rand -base64 24 | tr -d "/+=" | head -c 24)"
JWT_SECRET="$(openssl rand -hex 32)"
ADMIN_PASSWORD="$(openssl rand -base64 18 | tr -d "/+=" | head -c 16)!"
PUBLIC_IP="$(curl -4 -fsS --max-time 10 https://ifconfig.me || curl -4 -fsS --max-time 10 https://api.ipify.org || hostname -I | awk '{print $1}' || true)"
cat > .env <<EOF
# Generated by DigitalOcean startup script $(date -u +%Y-%m-%dT%H:%M:%SZ)
MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
MYSQL_DB=trackarrow
MYSQL_USER=trackarrow
MYSQL_PASSWORD=${MYSQL_PASSWORD}
MYSQL_HOST_PORT=3306
REDIS_PASSWORD=${REDIS_PASSWORD}
REDIS_HOST_PORT=6379
CH_DB=trackarrow
CH_USER=trackarrow
CH_PASSWORD=${CH_PASSWORD}
CH_HTTP_HOST_PORT=8123
CH_NATIVE_HOST_PORT=9000
ADMINER_HOST_PORT=8888
API_HOST_PORT=4000
FRONTEND_HOST_PORT=${FRONTEND_HOST_PORT}
VITE_API_URL=http://api:4000
JWT_SECRET=${JWT_SECRET}
ADMIN_LOGIN=admin
ADMIN_PASSWORD=${ADMIN_PASSWORD}
TZ=UTC
TZ_OFFSET_MINUTES=0
TRACKARROW_VERSION=${FEED_VERSION}
INSTALL_DIR=${INSTALL_DIR}
TRACKARROW_CREDENTIALS_FILE=/root/trackarrow-credentials.txt
UPDATES_BASE_URL=${UPDATES_BASE}
SERVER_IPV4=${PUBLIC_IP}
SERVER_IPV6=
NGINX_HOST_PORT=8080
ACME_EMAIL=${LETSENCRYPT_EMAIL}
ACME_STAGING=false
TRACKARROW_CERTS_DIR=/etc/trackarrow/certificates
TRACKARROW_ACME_DIR=/etc/trackarrow/acme
CADDY_CONFIG_DIR=/etc/caddy
CADDY_ADMIN_URL=http://host.docker.internal:2019
CADDY_SITE_DOMAIN=${DOMAIN}
LICENSE_API_URL=${LICENSE_API_URL}
LICENSE_MANAGER_URL=https://account.trackarrow.io
LICENSE_API_SECRET=
LICENSE_KEY=${LICENSE_KEY}
LICENSE_TOKEN=${LICENSE_TOKEN}
TRACKARROW_SSH_DIR=/root/.ssh
EOF
chmod 600 .env
APP_URL_LINE="http://${PUBLIC_IP:-YOUR_DROPLET_IP}/"
API_URL_LINE="http://${PUBLIC_IP:-YOUR_DROPLET_IP}:4000"
if [ -n "$DOMAIN" ]; then
APP_URL_LINE="https://${DOMAIN}/"
API_URL_LINE="https://${DOMAIN}/api"
fi
DOMAIN_LABEL="${DOMAIN:-none (using Droplet IP)}"
cat > /root/trackarrow-credentials.txt <<EOF
TrackArrow — DigitalOcean install
==================================
STATUS: secrets generated — docker may still be starting
Domain: ${DOMAIN_LABEL}
App URL: ${APP_URL_LINE}
API: ${API_URL_LINE}
Adminer: ssh -L 8888:127.0.0.1:8888 root@${PUBLIC_IP:-YOUR_DROPLET_IP} then http://127.0.0.1:8888
Install dir: ${INSTALL_DIR}
Log file: /var/log/trackarrow-install.log
Login: admin
Password: ${ADMIN_PASSWORD}
MySQL user: trackarrow / ${MYSQL_PASSWORD}
Redis: localhost only (password in .env REDIS_PASSWORD)
ClickHouse: trackarrow / ${CH_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
If the site is down, check:
tail -n 100 /var/log/trackarrow-install.log
cd ${INSTALL_DIR} && docker compose ps
cd ${INSTALL_DIR} && docker compose pull && docker compose up -d
systemctl status caddy --no-pager || true
EOF
chmod 600 /root/trackarrow-credentials.txt
cp -a /root/trackarrow-credentials.txt "$INSTALL_DIR/trackarrow-credentials.txt"
chmod 600 "$INSTALL_DIR/trackarrow-credentials.txt"
mkdir -p /root/.ssh
chmod 700 /root/.ssh
touch /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
echo "[trackarrow] Credentials written to /root/trackarrow-credentials.txt"
mkdir -p /etc/trackarrow/certificates /etc/trackarrow/acme
chmod 755 /etc/trackarrow /etc/trackarrow/certificates /etc/trackarrow/acme
# ── Caddy package FIRST (before Docker). If /etc/caddy/Caddyfile already exists,
# apt will prompt and fail on a headless droplet (dpkg EOF / missing caddy user).
if [ -n "$DOMAIN" ]; then
echo "[trackarrow] Installing Caddy for https://${DOMAIN} (before Docker)..."
export NEEDRESTART_MODE=l
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https || true
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 -y
apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold caddy \
|| dpkg --configure -a || true
apt-get install -y -f -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold caddy || true
if ! getent passwd caddy >/dev/null 2>&1; then
echo "[trackarrow] WARNING: caddy user missing — creating system user"
useradd --system --home /var/lib/caddy --shell /usr/sbin/nologin --comment "Caddy web server" caddy || true
mkdir -p /var/lib/caddy /var/log/caddy
chown -R caddy:caddy /var/lib/caddy /var/log/caddy || true
fi
mkdir -p /etc/caddy
cat > /etc/caddy/Caddyfile <<EOF
{
email ${LETSENCRYPT_EMAIL}
admin 0.0.0.0:2019 {
origins 0.0.0.0:2019 127.0.0.1:2019 localhost:2019 host.docker.internal:2019 172.17.0.1:2019
}
}
http:// {
handle /.well-known/acme-challenge/* {
reverse_proxy 127.0.0.1:4000
}
handle {
reverse_proxy 127.0.0.1:4000
}
}
${DOMAIN} {
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
handle /api* {
reverse_proxy 127.0.0.1:4000
}
handle /admin_api* {
reverse_proxy 127.0.0.1:4000
}
handle /click_api* {
reverse_proxy 127.0.0.1:4000
}
handle /postback* {
reverse_proxy 127.0.0.1:4000
}
@trackarrow_pb path_regexp trackarrow_pb ^/[^/]+/postback/?$
handle @trackarrow_pb {
reverse_proxy 127.0.0.1:4000
}
handle {
reverse_proxy 127.0.0.1:3030
}
}
EOF
systemctl enable caddy || true
cat > /etc/systemd/system/caddy-trackarrow-reload.service <<'UNIT'
[Unit]
Description=Reload Caddy after TrackArrow writes Caddyfile
After=caddy.service
[Service]
Type=oneshot
ExecStartPre=-/bin/chown -R caddy:caddy /etc/trackarrow/certificates
ExecStartPre=-/bin/chmod -R u+rX /etc/trackarrow/certificates
ExecStart=/bin/systemctl reload-or-restart caddy.service
UNIT
cat > /etc/systemd/system/caddy-trackarrow-reload.path <<'UNIT'
[Unit]
Description=Watch /etc/caddy/Caddyfile for TrackArrow SSL updates
[Path]
PathModified=/etc/caddy/Caddyfile
Unit=caddy-trackarrow-reload.service
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload || true
systemctl enable --now caddy-trackarrow-reload.path || true
fi
# ── Firewall ─────────────────────────────────────────────────────────────────
# UFW alone is not enough: Docker publishes ports via iptables and bypasses UFW.
# Database ports are bound to 127.0.0.1 in docker-compose.yml. Also drop those
# ports on the public NIC (DOCKER-USER runs before Docker ACCEPT rules).
ufw allow OpenSSH || true
ufw allow 80/tcp || true
ufw allow 443/tcp || true
# Docker API → host Caddy admin (do not expose 2019 to the public internet)
ufw allow from 172.16.0.0/12 to any port 2019 proto tcp || true
ufw allow from 192.168.0.0/16 to any port 2019 proto tcp || true
if [ -z "$DOMAIN" ]; then
ufw allow 4000/tcp || true
fi
ufw --force enable || true
PUB_IFACE="$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}')"
PUB_IFACE="${PUB_IFACE:-eth0}"
for p in 3306 6379 6380 8123 9000 8888 2019; do
iptables -C DOCKER-USER -i "$PUB_IFACE" -p tcp --dport "$p" -j DROP 2>/dev/null \
|| iptables -I DOCKER-USER -i "$PUB_IFACE" -p tcp --dport "$p" -j DROP || true
done
mkdir -p /etc/trackarrow
cat > /etc/trackarrow/docker-user-ports.sh <<'EOS'
#!/bin/sh
PUB_IFACE="$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}')"
PUB_IFACE="${PUB_IFACE:-eth0}"
for p in 3306 6379 6380 8123 9000 8888 2019; do
iptables -C DOCKER-USER -i "$PUB_IFACE" -p tcp --dport "$p" -j DROP 2>/dev/null \
|| iptables -I DOCKER-USER -i "$PUB_IFACE" -p tcp --dport "$p" -j DROP || true
done
EOS
chmod 700 /etc/trackarrow/docker-user-ports.sh
cat > /etc/systemd/system/trackarrow-docker-user-ports.service <<'UNIT'
[Unit]
Description=Drop public traffic to TrackArrow database ports (Docker bypasses UFW)
After=docker.service
Wants=docker.service
[Service]
Type=oneshot
ExecStart=/etc/trackarrow/docker-user-ports.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload || true
systemctl enable --now trackarrow-docker-user-ports.service || true
# ── Start stack ──────────────────────────────────────────────────────────────
echo "[trackarrow] Starting docker compose (this can take several minutes)..."
if ! docker compose pull; then
echo "[trackarrow] Version tag ${FEED_VERSION} was denied — retrying channel tag ${LICENSE_CHANNEL}..."
sed -i.bak "s/^TRACKARROW_VERSION=.*/TRACKARROW_VERSION=${LICENSE_CHANNEL}/" .env
rm -f .env.bak
export TRACKARROW_VERSION="$LICENSE_CHANNEL"
if ! docker compose pull; then
echo "[trackarrow] FATAL: docker compose pull denied. Generate a License Token on trackarrow.io (Licenses) and export LICENSE_TOKEN=ta_…"
#echo "[trackarrow] Also confirm ghcr.io/trackarrow/trackarrow-api:${LICENSE_CHANNEL} exists and GITHUB_PACKAGES_TOKEN (classic read:packages) is set on License Manager."
docker compose ps || true
exit 1
fi
fi
if ! docker compose up -d; then
echo "[trackarrow] ERROR: docker compose up failed — credentials already saved."
echo "[trackarrow] On 2 GB Droplets this is often OOM. Resize to 4 GB, then:"
echo "[trackarrow] cd $INSTALL_DIR && docker compose pull && docker compose up -d"
docker compose ps || true
free -h || true
dmesg | tail -n 40 || true
exit 1
fi
if ss -lnt | grep -q "0.0.0.0:6379"; then
echo "[trackarrow] WARNING: Redis is listening on 0.0.0.0:6379 — not loopback-only"
else
echo "[trackarrow] Redis is not published on the public IPv4 address"
fi
echo "[trackarrow] Waiting for API / frontend..."
READY=0
for i in $(seq 1 90); do
if curl -fsS "http://127.0.0.1:4000/api/health" >/dev/null 2>&1; then
READY=1
echo "[trackarrow] API responding"
break
fi
sleep 5
done
# ── HTTPS via Caddy (package already installed before Docker) ────────────────
if [ -n "$DOMAIN" ]; then
echo "[trackarrow] Waiting for DNS ${DOMAIN} → ${PUBLIC_IP} (up to ~10 min)..."
DNS_OK=0
for i in $(seq 1 60); do
RESOLVED="$(dig +short A "$DOMAIN" 2>/dev/null | tail -n1 || true)"
echo "[trackarrow] DNS check: ${DOMAIN} => ${RESOLVED:-none} (expect ${PUBLIC_IP})"
if [ -n "$RESOLVED" ] && [ "$RESOLVED" = "$PUBLIC_IP" ]; then
DNS_OK=1
break
fi
sleep 10
done
if [ "$DNS_OK" != "1" ]; then
echo "[trackarrow] WARNING: DNS not pointing to this Droplet yet — Caddy will retry TLS later."
fi
cat > /etc/caddy/Caddyfile <<EOF
{
email ${LETSENCRYPT_EMAIL}
admin 0.0.0.0:2019 {
origins 0.0.0.0:2019 127.0.0.1:2019 localhost:2019 host.docker.internal:2019 172.17.0.1:2019
}
}
http:// {
handle /.well-known/acme-challenge/* {
reverse_proxy 127.0.0.1:4000
}
handle {
reverse_proxy 127.0.0.1:4000
}
}
${DOMAIN} {
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
handle /api* {
reverse_proxy 127.0.0.1:4000
}
handle /admin_api* {
reverse_proxy 127.0.0.1:4000
}
handle /click_api* {
reverse_proxy 127.0.0.1:4000
}
handle /postback* {
reverse_proxy 127.0.0.1:4000
}
@trackarrow_pb path_regexp trackarrow_pb ^/[^/]+/postback/?$
handle @trackarrow_pb {
reverse_proxy 127.0.0.1:4000
}
handle {
reverse_proxy 127.0.0.1:3030
}
}
EOF
systemctl enable caddy
systemctl restart caddy
sleep 3
systemctl --no-pager --full status caddy || true
echo "[trackarrow] Waiting for HTTPS..."
TLS_OK=0
for i in $(seq 1 36); do
if curl -fsS "https://${DOMAIN}/api/health" >/dev/null 2>&1; then
TLS_OK=1
echo "[trackarrow] HTTPS OK: https://${DOMAIN}/"
break
fi
sleep 5
done
if [ "$TLS_OK" != "1" ]; then
echo "[trackarrow] WARNING: https://${DOMAIN} not ready yet."
echo "[trackarrow] Ensure A record ${DOMAIN} → ${PUBLIC_IP}, then: systemctl restart caddy"
journalctl -u caddy -n 40 --no-pager || true
fi
fi
if [ "$READY" = "1" ]; then
if [ -n "$DOMAIN" ] && [ "${TLS_OK:-0}" = "1" ]; then
sed -i "s/^STATUS:.*/STATUS: OK — stack up + HTTPS/" /root/trackarrow-credentials.txt || true
else
sed -i "s/^STATUS:.*/STATUS: OK — stack is up/" /root/trackarrow-credentials.txt || true
fi
else
sed -i "s/^STATUS:.*/STATUS: WARNING — compose started but HTTP not ready yet/" /root/trackarrow-credentials.txt || true
echo "[trackarrow] WARNING: HTTP not ready yet — check: docker compose -f $INSTALL_DIR/docker-compose.yml ps"
fi
echo "[trackarrow] Install finished at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "[trackarrow] Credentials: /root/trackarrow-credentials.txt"
docker compose ps || true
free -h || true
Same file as https://docs.trackarrow.io/releases/install.sh. If you already run a tracker, Settings → Install shows this script again for a second Droplet.
SSH into the Droplet and read the credentials file:
cat /root/trackarrow-credentials.txt
Open the admin URL from that file (or http://YOUR_DROPLET_IP/ if no domain). Then continue with Step 2. Create an administrator.
Install log: /var/log/trackarrow-install.log.
Create a firewall right after the Droplet is up. This is required, not optional. Docker can bypass UFW on the Droplet, so a DigitalOcean Cloud Firewall is the reliable way to hide Redis and the other databases.
| Type | Protocol | Port | Sources |
|---|---|---|---|
| SSH | TCP | 22 | All IPv4 (add All IPv6 if you SSH over IPv6) |
| HTTP | TCP | 80 | All IPv4, All IPv6 |
| HTTPS | TCP | 443 | All IPv4, All IPv6 |
| Type | Protocol | Port | Destinations |
|---|---|---|---|
| ICMP | ICMP | — | All IPv4, All IPv6 |
| All TCP | TCP | All ports | All IPv4, All IPv6 |
| All UDP | UDP | All ports | All IPv4, All IPv6 |
Do not restrict outbound. If you remove “All TCP / All UDP”, updates, certificates, and postbacks will fail. The Redis warning from DigitalOcean is about inbound port 6379, not outbound.
From your computer, check that Redis on port 6379 is closed. Connection refused or timed out is correct. If the connection succeeds, the port is still public.
Test-NetConnection YOUR_DROPLET_IP -Port 6379 — you want TcpTestSucceeded : False.nc -vz -w 3 YOUR_DROPLET_IP 6379nc -vz -w 3 YOUR_DROPLET_IP 6379 (install netcat if nc is missing)