#!/bin/bash # services/anki-sync-server.sh — Self-hosted Anki flashcard sync server. # Part of the modular post-install system (sourced by setup.sh). # # Can also be run standalone on any machine: # sudo bash anki-sync-server.sh # (Docker must already be installed when run standalone) # ── Standalone bootstrap ────────────────────────────────────────────────────── # Detected when the script is executed directly rather than sourced by setup.sh. # Sets up helpers and globals, then defers execution until after the function # definition at the bottom of this file. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" _COMMON="$_SELF_DIR/../lib/common.sh" if [[ -f "$_COMMON" ]]; then # Full repo present — use the real helpers (picks up ~/docker/.config too) # shellcheck source=../lib/common.sh source "$_COMMON" else # One-off copy — inline minimal stubs so the script works without the repo log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } require_docker() { command -v docker &>/dev/null || { log_error "Docker not found. Install it first:" log_error " curl -fsSL https://get.docker.com | sudo sh" return 1 } docker compose version &>/dev/null || { log_error "Docker Compose plugin missing:" log_error " sudo apt-get install -y docker-compose-plugin" return 1 } } ensure_docker_dir_ownership() { chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } port_in_use() { local _port="$1" _proto="${2:-tcp}" local _flag="-tlnH" [ "$_proto" = "udp" ] && _flag="-ulnH" ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . } find_free_port() { local _varname="$1" _port="$2" _proto="${3:-tcp}" while port_in_use "$_port" "$_proto"; do _port=$((_port + 1)) done eval "$_varname='$_port'" } # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } prompt_yn() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } prompt_reinstall_mode() { local _var="$1" _r if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi echo " Already installed." read -r -p " (u)pdate / (f)resh reinstall / (c)ancel [c]: " _r case "${_r,,}" in u|update) eval "$_var='update'" ;; f|fresh) eval "$_var='fresh'" ;; *) eval "$_var='cancel'" ;; esac } generate_password() { local length="${1:-32}" openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" } configure_caddy_for_service() { local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" local _display_port="${_upstream##*:}" # Determine mode: local Caddy, remote Caddy, or none local _mode="none" [[ -d "$_caddy_dir" ]] && _mode="local" [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" [[ "$_mode" == "none" ]] && { log_info "Access $_name directly on port $_display_port." return 0 } echo "" local _do_caddy="" if [[ "$_mode" == "remote" ]]; then log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." log_info "A snippet file will be saved to ~/docker/caddy-snippets/." fi read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy [[ "${_do_caddy,,}" == "y" ]] || { log_info "Skipping — access at: http://localhost:$_display_port" return 0 } # Domain prompt — pre-fill from SITE_DOMAIN when available local _default_domain="" if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then _default_domain="${_subdomain}.${SITE_DOMAIN}" log_info "Default: $_default_domain" fi local _domain="" read -r -p " Domain [${_default_domain:-required}]: " _domain _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } # Build upstream — remote Caddy uses host IP:port, not container name local _block_upstream="$_upstream" if [[ "$_mode" == "remote" ]]; then _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi local _site_block _site_block="$(cat << CBLOCK # $_name ${_domain} { reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" Referrer-Policy "strict-origin-when-cross-origin" } log { output file /var/log/caddy/${_domain}.log format json } ${_extra} } CBLOCK )" if [[ "$_mode" == "local" ]]; then if [[ -f "$_caddyfile" ]]; then local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" cp "$_caddyfile" "$_bk" log_info "Backed up Caddyfile to $(basename "$_bk")" else touch "$_caddyfile" fi if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then log_warning "$_domain already in Caddyfile" local _ow="" read -r -p " Overwrite? [y/N]: " _ow [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } sed -i "/^${_domain}/,/^}/d" "$_caddyfile" fi printf '%s\n' "$_site_block" >> "$_caddyfile" log_success "Added $_domain to Caddyfile" docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then log_success "$_name accessible at: https://$_domain" else log_warning "Reload failed — check: docker logs caddy" log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" fi else local _snippet_dir="$DOCKER_DIR/caddy-snippets" local _snippet_file="$_snippet_dir/${_subdomain}.caddy" mkdir -p "$_snippet_dir" printf '%s\n' "$_site_block" > "$_snippet_file" chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true log_success "Snippet saved: $_snippet_file" log_info "Copy to Caddy machine:" log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } write_readme() { local _dir="$1"; shift mkdir -p "$_dir" cat > "$_dir/README.md" } backup_if_exists() { local _file="$1" [ -f "$_file" ] || return 0 cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null } fi # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # ($HOME under sudo is /root, not the real user's home) ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" DRY_RUN="${DRY_RUN:-false}" UNATTENDED="${UNATTENDED:-false}" SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── register_service anki-sync-server utilities "Self-hosted Anki flashcard sync server (spaced repetition, syncs across devices without AnkiWeb)" 8080 # Reads the current ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs out of an # instance's .env into the caller's ANKI_USERS/ANKI_PASSWORDS arrays (bash's # dynamic scoping means a `local` array declared in the caller is visible # here without being passed explicitly — same assumption every other helper # below makes). Numbering is always kept contiguous from 1 by # _anki_rewrite_account_block, so stopping at the first missing index is # safe — there's never a gap to skip over. _anki_load_accounts() { local _dir="$1" _n=1 _u _p ANKI_USERS=() ANKI_PASSWORDS=() while true; do _u="$(grep "^ANKI_SYNC_USER${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)" [ -z "$_u" ] && break _p="$(grep "^ANKI_SYNC_PASSWORD${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)" ANKI_USERS+=("$_u") ANKI_PASSWORDS+=("$_p") _n=$((_n + 1)) done } # Regenerates the SYNC_USERn=... lines in docker-compose.yml and the # matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs in .env from the # caller's current ANKI_USERS/ANKI_PASSWORDS arrays (always renumbered # contiguously from 1 — see _anki_load_accounts). Used by both the initial # install and every account-management mutation (add/remove/rotate) so the # two never drift apart, same reasoning as CLAUDE.md's shared-helper # guidance for update vs. fresh-install codepaths. Leaves the port, Caddy # block, and every other line in either file untouched — only lines # matching the SYNC_USER/ANKI_SYNC_* patterns are touched. _anki_rewrite_account_block() { local _dir="$1" local _compose="$_dir/docker-compose.yml" local _env="$_dir/.env" sed -i '/^ - SYNC_USER[0-9]\+=/d' "$_compose" sed -i '/^ANKI_SYNC_USER[0-9]\+=/d; /^ANKI_SYNC_PASSWORD[0-9]\+=/d' "$_env" local _compose_lines="" _env_lines="" i idx for i in "${!ANKI_USERS[@]}"; do idx=$((i + 1)) _compose_lines+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}} " _env_lines+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]} ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]} " done # Insert right after the fixed SYNC_BASE anchor line — always present, # written by every version of this script's install flow — instead of # appending at the end, so the block stays grouped with SYNC_HOST/ # SYNC_PORT/SYNC_BASE rather than drifting after `volumes:`. local _tmp _tmp="$(mktemp)" printf '%s' "$_compose_lines" > "$_tmp" sed -i "\|^ - SYNC_BASE=/data\$|r $_tmp" "$_compose" rm -f "$_tmp" printf '%s' "$_env_lines" >> "$_env" } # Interactive add/remove/rotate menu for an existing instance's sync # accounts, offered from install_anki-sync-server's "already installed" # menu. Every mutation restarts the container (`docker compose up -d` # re-reads .env for the new/removed/rotated credentials) but never touches # the port, Caddy config, or the image — the things CLAUDE.md's "update vs. # fresh reinstall" convention says a non-destructive path must leave alone. _anki_manage_accounts() { local _dir="$1" local ANKI_USERS=() ANKI_PASSWORDS=() while true; do _anki_load_accounts "$_dir" echo "" echo " Current sync accounts:" local i for i in "${!ANKI_USERS[@]}"; do echo " $((i + 1))) ${ANKI_USERS[$i]}" done [ "${#ANKI_USERS[@]}" -eq 0 ] && echo " (none)" echo "" echo " a) Add an account" echo " r) Remove an account" echo " p) Rotate (reset) an account's password" echo " 0) Done" echo "" local ACTION="" prompt_text " Choice [a/r/p/0]:" "0" ACTION case "$ACTION" in a|A) if [ "${#ANKI_USERS[@]}" -ge 8 ]; then log_warning "That's plenty — stopping at 8 accounts." continue fi local _u="" prompt_text " New username:" "" _u if [ -z "$_u" ]; then log_warning "Name can't be empty."; continue fi ANKI_USERS+=("$_u") ANKI_PASSWORDS+=("$(generate_password 24)") _anki_rewrite_account_block "$_dir" ( cd "$_dir" && docker compose up -d ) \ && log_success "Account '$_u' added — password: ${ANKI_PASSWORDS[-1]} (also saved in $_dir/.env)" \ || log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs" ;; r|R) if [ "${#ANKI_USERS[@]}" -eq 0 ]; then log_warning "No accounts to remove."; continue fi local _n="" prompt_text " Remove which number?" "" _n if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then log_warning "Invalid choice."; continue fi local _removed="${ANKI_USERS[$((_n - 1))]}" unset 'ANKI_USERS[_n - 1]' 'ANKI_PASSWORDS[_n - 1]' ANKI_USERS=("${ANKI_USERS[@]}") ANKI_PASSWORDS=("${ANKI_PASSWORDS[@]}") _anki_rewrite_account_block "$_dir" ( cd "$_dir" && docker compose up -d ) \ && log_success "Account '$_removed' removed" \ || log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs" ;; p|P) if [ "${#ANKI_USERS[@]}" -eq 0 ]; then log_warning "No accounts yet."; continue fi local _n="" prompt_text " Rotate password for which number?" "" _n if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then log_warning "Invalid choice."; continue fi ANKI_PASSWORDS[$((_n - 1))]="$(generate_password 24)" _anki_rewrite_account_block "$_dir" ( cd "$_dir" && docker compose up -d ) \ && log_success "New password for '${ANKI_USERS[$((_n - 1))]}': ${ANKI_PASSWORDS[$((_n - 1))]} (also saved in $_dir/.env)" \ || log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs" ;; 0) break ;; *) log_warning "Unrecognized choice." ;; esac done } install_anki-sync-server() { require_docker || return 1 log_info "Installing Anki Sync Server..." # ── Instance selection ─────────────────────────────────────────────────── # First instance keeps the plain "anki-sync-server" name/paths/port exactly # as before (zero behavior change for anyone with a single instance). Only # asking to add a second one introduces suffixed naming — same pattern as # services/ntfy.sh and services/homebox.sh. A second instance is a real # use case here (e.g. a second household wanting fully separate data on # the same box) even though one instance already supports multiple # independent accounts via SYNC_USER1/SYNC_USER2/... — see CLAUDE.md's # "Multi-instance services" section. local ANKI_DIR="$DOCKER_DIR/anki-sync-server" local INSTANCE_SUFFIX="" CONTAINER="anki-sync-server" local WEB_PORT="8080" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" echo "[DRY-RUN] Would create $ANKI_DIR(-)" echo "[DRY-RUN] Would prompt for one or more sync accounts and generate passwords" echo "[DRY-RUN] Would write docker-compose.yml and .env" echo "[DRY-RUN] Would auto-scan for a free host port" return 0 fi if [ -d "$ANKI_DIR" ]; then echo "" echo " Anki Sync Server is already installed at $ANKI_DIR." echo " 1) Manage sync accounts (add / remove / rotate a password — doesn't" echo " touch the port, Caddy, or the image)" echo " 2) Manage that install (update image / full reinstall / cancel)" echo " 3) Add a NEW, separate Anki Sync Server instance alongside it (its" echo " own data and port — full isolation)" echo "" local _TOP_CHOICE="" prompt_text " Choice [1/2/3]:" "2" _TOP_CHOICE if [ "$_TOP_CHOICE" = "1" ]; then _anki_manage_accounts "$ANKI_DIR" return 0 elif [ "$_TOP_CHOICE" = "3" ]; then local _suffix="" while true; do prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" if [ -z "$_suffix" ]; then log_warning "Name can't be empty."; continue fi if [ -d "$DOCKER_DIR/anki-sync-server-$_suffix" ]; then log_warning "anki-sync-server-$_suffix already exists — pick another name."; continue fi break done INSTANCE_SUFFIX="$_suffix" ANKI_DIR="$DOCKER_DIR/anki-sync-server-$_suffix" CONTAINER="anki-sync-server-$_suffix" log_info "New instance: $ANKI_DIR" else # "Manage that install" on THIS instance — the banner above promises # update/fresh/cancel, so actually offer it instead of falling straight # through into the same unconditional-overwrite flow as a new install. if [[ -f "$ANKI_DIR/docker-compose.yml" ]]; then local MODE="" prompt_reinstall_mode MODE case "$MODE" in update) log_info "Refreshing the Anki Sync Server image only — existing accounts, port, and Caddy setup are left as-is." # The image is a Google distroless "nonroot" build (fixed UID/GID # 65532, no shell — it can't chown anything itself at startup), so # ./data has to already be writable by that exact UID or the # container fails to start. Versions of this installer before this # fix chowned it to ACTUAL_USER instead, which the container can't # write to — re-asserting the correct ownership here repairs any # install made under that bug, non-destructively (it's the # installer's own bug being corrected, not a config choice, so it # belongs in the non-destructive update path). chown -R 65532:65532 "$ANKI_DIR/data" 2>/dev/null ( cd "$ANKI_DIR" && docker compose pull && docker compose up -d ) \ && log_success "Anki Sync Server image refreshed" \ || log_warning "Refresh failed — check: docker compose -f $ANKI_DIR/docker-compose.yml logs" return 0 ;; cancel) log_info "Leaving the existing install as-is." return 0 ;; fresh) ;; # fall through to the full install flow below esac fi fi fi # Scan for a free port unconditionally — not just when adding an explicit # additional instance. A plain first install can just as easily collide # with an unrelated service that already claimed this default port — see # CLAUDE.md's "Port collision avoidance" section. find_free_port WEB_PORT "$WEB_PORT" # ── Sync accounts ───────────────────────────────────────────────────────── # The official sync server has no signup flow of its own — accounts are # fixed credentials baked in as SYNC_USER1, SYNC_USER2, ... at container # start, one per line in .env. Ask for at least one now (each Anki client # — desktop, AnkiDroid, AnkiMobile — logs in with one of these) and offer # to add more for other people sharing this box, since a single instance # already keeps each account's collection completely separate. local ANKI_USERS=() ANKI_PASSWORDS=() local _u="" prompt_text " Username for your Anki sync account:" "$ACTUAL_USER" _u ANKI_USERS+=("$_u") ANKI_PASSWORDS+=("$(generate_password 24)") while true; do local _more="" prompt_yn " Add another Anki sync account (e.g. for a family member)? (y/n):" "n" _more [[ "$_more" =~ ^[Yy]$ ]] || break prompt_text " Username for the additional account:" "" _u if [ -z "$_u" ]; then log_warning "Name can't be empty."; continue fi ANKI_USERS+=("$_u") ANKI_PASSWORDS+=("$(generate_password 24)") if [ "${#ANKI_USERS[@]}" -ge 8 ]; then log_warning "That's plenty — stopping at 8 accounts." break fi done mkdir -p "$ANKI_DIR/data" ensure_docker_dir_ownership "$ANKI_DIR" cd "$ANKI_DIR" || return 1 # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): # explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy, # then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a # remote Caddy box can't resolve container names on this host's bridge # network anyway; it reaches this service via the host's published port. local _CADDY_MODE="${CADDY_MODE:-none}" [ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local" [ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote" local _CADDY_NET_BLOCK="" local _CADDY_NET_SECTION="" if [ "$_CADDY_MODE" = "local" ]; then _CADDY_NET_BLOCK=" networks: - caddy_net " _CADDY_NET_SECTION=" networks: caddy_net: external: true name: ${SITE_CADDY_NET:-caddy_net} " fi # Build the SYNC_USERn=... lines for docker-compose.yml (compose-time # interpolation of ${ANKI_SYNC_USERn}/${ANKI_SYNC_PASSWORDn} from .env — # same \${VAR} pattern services/homebox.sh uses for its own .env values) # and the matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn lines for .env. local _COMPOSE_USER_LINES="" _ENV_USER_LINES="" i idx for i in "${!ANKI_USERS[@]}"; do idx=$((i + 1)) _COMPOSE_USER_LINES+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}} " _ENV_USER_LINES+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]} ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]} " done backup_if_exists docker-compose.yml cat > docker-compose.yml << ANKI_COMPOSE name: $CONTAINER services: anki-sync-server: image: afrima/anki-sync-server:latest container_name: $CONTAINER hostname: $CONTAINER restart: unless-stopped environment: - SYNC_HOST=0.0.0.0 - SYNC_PORT=8080 - SYNC_BASE=/data ${_COMPOSE_USER_LINES} volumes: - ./data:/data ports: - "${WEB_PORT}:8080" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ANKI_COMPOSE backup_if_exists .env cat > .env << ANKI_ENV TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} CADDY_NET=$SITE_CADDY_NET # One username/password pair per Anki sync account (SYNC_USER1, SYNC_USER2, # ... in docker-compose.yml). Enter these exact values as the account on # each Anki client (Preferences/Settings → self-hosted sync server). To add, # remove, or reset one of these later, re-run this installer against the # existing install and pick "Manage sync accounts" — don't hand-edit these # lines, the matching docker-compose.yml lines have to change in lockstep. ${_ENV_USER_LINES} ANKI_ENV chmod 600 .env chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ANKI_DIR" # afrima/anki-sync-server is built on gcr.io/distroless/static-debian12:nonroot # — the process always runs as that image's fixed "nonroot" UID/GID (65532), # never as ACTUAL_USER, and distroless has no shell so nothing inside the # container can chown its own data dir at startup. Applied AFTER the # ACTUAL_USER chown above (not before — that call would just clobber it, # since it recurses over the whole $ANKI_DIR including data/) so ./data ends # up owned by 65532 specifically while docker-compose.yml/.env/README.md # (which the sysadmin edits, not the container) stay owned by ACTUAL_USER. # Confirmed live: getting this wrong is exactly what makes the container # fail to come up with a permissions error the moment it tries to create # anything under /data (e.g. a new user's collection). chown -R 65532:65532 "$ANKI_DIR/data" echo "" log_success "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $ANKI_DIR (port $WEB_PORT)" echo "" echo " Sync accounts (also saved in $ANKI_DIR/.env):" for i in "${!ANKI_USERS[@]}"; do echo " ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}" done echo "" # No Authelia gate here, unlike most other web-facing services in this # repo: this is a raw HTTP sync API that the Anki client itself talks to # (not a browser session), so a forward_auth login portal in front of it # would just break every sync request instead of protecting anything. # SYNC_USER1/SYNC_USER2/... above is this service's own auth boundary — # same reasoning as the has-built-in-auth services in CLAUDE.md, just # with no web UI to additionally gate. configure_caddy_for_service "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "anki${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" local START="" prompt_yn "Start Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START if [ "$START" = "y" ] || [ "$START" = "Y" ]; then docker compose up -d \ && log_success "Anki Sync Server started" \ || log_warning "Start failed — check: docker compose logs" fi write_readme "$ANKI_DIR" << MD # Anki Sync Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted sync server for the [Anki](https://apps.ankiweb.net/) flashcard app — syncs your collection across devices without going through AnkiWeb. Anki's own spaced-repetition scheduler (FSRS) gives failed cards more repetition and correctly-recalled cards longer gaps automatically; nothing here changes that, it's purely the sync backend. $( [ -n "$INSTANCE_SUFFIX" ] && echo " This is a separate, fully isolated instance (own data directory, own accounts, own port) — not shared collections with another Anki Sync Server instance.") ## Access - Sync URL: $( [ -n "${CADDY_SERVICE_CONFIGURED:-}" ] && [ "$CADDY_SERVICE_CONFIGURED" = "true" ] && echo "https://${CADDY_SERVICE_DOMAIN}/" || echo "http://localhost:${WEB_PORT}/" ) - Accounts (username / password): $(for i in "${!ANKI_USERS[@]}"; do echo " - ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}"; done) Enter the Sync URL and one of the above accounts on each Anki client — see the client setup section below for exactly where. ## Data - Collections: \`$ANKI_DIR/data\` - Credentials: \`$ANKI_DIR/.env\` (readable by $ACTUAL_USER only) ## Manage \`\`\`bash cd $ANKI_DIR docker compose up -d docker compose down docker compose logs -f docker compose pull && docker compose up -d \`\`\` To add, remove, or reset the password of a sync account later, re-run the installer against this install and pick **"Manage sync accounts"** — don't hand-edit \`.env\`, the matching lines in \`docker-compose.yml\` have to change alongside it: \`\`\`bash sudo ./setup.sh anki-sync-server \`\`\` MD log_info "Full client setup + Quizlet import walkthrough written to $ANKI_DIR/README.md" } # ── Standalone execution ─────────────────────────────────────────────────── if [[ "${_RUN_STANDALONE:-0}" == "1" ]]; then install_anki-sync-server fi