Auto-installation on DigitalOcean

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.

Recommended Droplet

WARNING

Do not use 1 GB or 2 GB Droplets. ClickHouse and MySQL will run out of memory.

Create the Droplet

  1. Sign in at digitalocean.com and click Create → Droplets.
  2. Choose Ubuntu 24.04, the recommended size, and a region.
  3. Under Additional Options, enable Startup scripts (user data).
  4. Copy the Startup script on this page (use Copy on the code block) and paste it into DigitalOcean. You do not need a tracker installed.
  5. In the pasted script replace 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.
  6. Add your SSH key and click Create Droplet. First boot takes about 5–15 minutes.
TIP

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.

Startup script

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.

After installation

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.

Cloud Firewall

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.

  1. In DigitalOcean open Networking → Firewalls → Create Firewall.
  2. Set inbound rules as in the table below. Do not add Redis 6379, MySQL 3306, ClickHouse, or Adminer.
  3. Leave the default outbound rules unchanged (ICMP + all TCP + all UDP). Track Arrow needs outbound for DNS, Let’s Encrypt, GitHub, Docker pulls, the license API, and affiliate postbacks.
  4. Apply the firewall to this Droplet.

Inbound (allow only these)

TypeProtocolPortSources
SSHTCP22All IPv4 (add All IPv6 if you SSH over IPv6)
HTTPTCP80All IPv4, All IPv6
HTTPSTCP443All IPv4, All IPv6

Outbound (keep DigitalOcean defaults)

TypeProtocolPortDestinations
ICMPICMP—All IPv4, All IPv6
All TCPTCPAll portsAll IPv4, All IPv6
All UDPUDPAll portsAll IPv4, All IPv6
WARNING

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.