Files
foxhunt/docs/plans/2026-02-22-dev-git-server-implementation.md
jgrusewski c3b5e124f0 chore: update .gitignore and add design plan docs
Ignore ML checkpoints, trained model safetensors, stray ml/ml/ dir,
and .claude/worktrees/. Clean up duplicate hive-mind-prompt entries.
Add 17 design/implementation plan docs from 2026-02-20 to 2026-02-22.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:30:15 +01:00

12 KiB

Dev Git Server Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Provision a Scaleway DEV1-S instance in nl-ams-1 running Gitea, accessible only via Tailscale, with fxhnt.ai DNS managed by Scaleway.

Architecture: Single VM with cloud-init bootstrap. Tailscale provides the network layer — Gitea binds exclusively to the Tailscale interface. No public IP after provisioning. SQLite database (no external dependencies).

Tech Stack: Scaleway CLI (scw), Tailscale, Gitea, Ubuntu 24.04, cloud-init


Task 1: Create Security Group

Create a temporary security group allowing inbound SSH for initial provisioning.

Step 1: Create the security group

scw instance security-group create \
  name=foxhunt-git-provisioning \
  inbound-default-policy=drop \
  outbound-default-policy=accept \
  zone=nl-ams-1 \
  project-id=c293eb98-228d-427d-9b16-f0941f3f2adb

Expected: Returns a security group ID. Save it as $SG_ID.

Step 2: Add SSH inbound rule

scw instance security-group-rule create \
  security-group-id=$SG_ID \
  direction=inbound \
  action=accept \
  protocol=TCP \
  dest-port-from=22 \
  zone=nl-ams-1

Expected: Rule created successfully.


Task 2: Write Cloud-Init Script

Create the cloud-init user-data script that bootstraps Tailscale and Gitea.

Step 1: Create the cloud-init file

Create file: docs/infra/cloud-init-git-server.yaml

#cloud-config
package_update: true
package_upgrade: true

packages:
  - git
  - sqlite3
  - curl
  - wget

write_files:
  - path: /etc/gitea/app.ini
    permissions: '0640'
    content: |
      [server]
      PROTOCOL         = http
      DOMAIN           = vm-fxhnt-git
      ROOT_URL         = http://vm-fxhnt-git:3000/
      HTTP_PORT        = 3000
      SSH_DOMAIN       = vm-fxhnt-git
      START_SSH_SERVER = true
      SSH_PORT         = 2222
      DISABLE_SSH      = false
      LFS_START_SERVER = true

      [database]
      DB_TYPE  = sqlite3
      PATH     = /var/lib/gitea/data/gitea.db

      [repository]
      ROOT = /var/lib/gitea/repositories

      [lfs]
      PATH = /var/lib/gitea/data/lfs

      [log]
      ROOT_PATH = /var/lib/gitea/log
      MODE      = file
      LEVEL     = Info

      [service]
      DISABLE_REGISTRATION = true

      [security]
      INSTALL_LOCK = false

runcmd:
  # Install Tailscale
  - curl -fsSL https://tailscale.com/install.sh | sh
  - tailscale up --authkey=tskey-api-kkwsqDRmg821CNTRL-d2z3w521Asa1d6Yb1CLatafwK3XJ6gA78 --hostname=vm-fxhnt-git --ssh

  # Wait for Tailscale interface
  - |
    for i in $(seq 1 30); do
      TS_IP=$(tailscale ip -4 2>/dev/null)
      if [ -n "$TS_IP" ]; then
        echo "Tailscale IP: $TS_IP"
        break
      fi
      sleep 2
    done

  # Get Tailscale IP for Gitea binding
  - TS_IP=$(tailscale ip -4)

  # Update Gitea config to bind to Tailscale IP only
  - sed -i "s/^HTTP_PORT.*/HTTP_ADDR = ${TS_IP}\nHTTP_PORT        = 3000/" /etc/gitea/app.ini

  # Create gitea user and directories
  - adduser --system --shell /bin/bash --gecos 'Gitea' --group --disabled-password --home /home/gitea gitea
  - mkdir -p /var/lib/gitea/{custom,data,log,repositories}
  - chown -R gitea:gitea /var/lib/gitea
  - chmod -R 750 /var/lib/gitea
  - mkdir -p /etc/gitea
  - chown root:gitea /etc/gitea
  - chmod 770 /etc/gitea

  # Download and install Gitea
  - |
    GITEA_VERSION=$(curl -s https://api.github.com/repos/go-gitea/gitea/releases/latest | grep tag_name | cut -d '"' -f 4 | sed 's/v//')
    wget -O /usr/local/bin/gitea "https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64"
    chmod +x /usr/local/bin/gitea

  # Create systemd service
  - |
    cat > /etc/systemd/system/gitea.service << 'UNIT'
    [Unit]
    Description=Gitea
    After=syslog.target network.target tailscaled.service

    [Service]
    RestartSec=2s
    Type=simple
    User=gitea
    Group=gitea
    ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini
    Restart=always
    Environment=USER=gitea HOME=/home/gitea GITEA_WORK_DIR=/var/lib/gitea

    [Install]
    WantedBy=multi-user.target
    UNIT

  - systemctl daemon-reload
  - systemctl enable gitea
  - systemctl start gitea

  # Lock down SSH to Tailscale only (after Tailscale SSH is working)
  - |
    TS_IP=$(tailscale ip -4)
    echo "ListenAddress ${TS_IP}" >> /etc/ssh/sshd_config
    systemctl restart sshd

  # Signal completion
  - echo "PROVISIONING_COMPLETE" > /var/log/cloud-init-done

Step 2: Verify the file is syntactically valid

python3 -c "import yaml; yaml.safe_load(open('docs/infra/cloud-init-git-server.yaml'))"

Expected: No output (valid YAML).


Task 3: Create the Scaleway Instance

Step 1: Create the instance with cloud-init

scw instance server create \
  name=vm-fxhnt-git \
  type=DEV1-S \
  image=ubuntu_noble \
  zone=nl-ams-1 \
  project-id=c293eb98-228d-427d-9b16-f0941f3f2adb \
  ip=new \
  cloud-init=@docs/infra/cloud-init-git-server.yaml \
  security-group-id=$SG_ID \
  tags.0=env=dev \
  tags.1=service=gitea \
  --wait

Expected: Instance created and running. Returns instance ID. Save as $INSTANCE_ID. Note: ip=new assigns a temporary public IP needed for cloud-init to download packages and authenticate with Tailscale.

Step 2: Monitor cloud-init progress

# Wait ~2-3 minutes for cloud-init, then check
scw instance server ssh $INSTANCE_ID zone=nl-ams-1 command="tail -20 /var/log/cloud-init-output.log"

Expected: Log showing Tailscale and Gitea installation steps. Look for PROVISIONING_COMPLETE.


Task 4: Verify Tailscale Connectivity

Step 1: Check Tailscale from workstation

tailscale ping vm-fxhnt-git

Expected: Pong from vm-fxhnt-git at a 100.x.x.x address. Note: If the old Azure vm-fxhnt-git is still on the network, there may be a conflict. The new node should take precedence if the old one is removed first — or Tailscale may assign a different name. Check tailscale status to verify.

Step 2: SSH via Tailscale

ssh root@vm-fxhnt-git "hostname && tailscale ip -4 && systemctl status gitea --no-pager"

Expected: Hostname vm-fxhnt-git, Tailscale IP shown, Gitea service active.

Step 3: Get the Tailscale IP for DNS records

TS_IP=$(ssh root@vm-fxhnt-git "tailscale ip -4")
echo "Tailscale IP: $TS_IP"

Save this IP for Task 6.


Task 5: Configure Gitea

Step 1: Create admin user via CLI

ssh root@vm-fxhnt-git "sudo -u gitea /usr/local/bin/gitea admin user create \
  --config /etc/gitea/app.ini \
  --username foxhunt-admin \
  --password '<PROMPT_USER_FOR_PASSWORD>' \
  --email admin@fxhnt.ai \
  --admin"

Expected: User created successfully. Ask the user for the admin password before running this.

Step 2: Lock the install

ssh root@vm-fxhnt-git "sed -i 's/INSTALL_LOCK.*/INSTALL_LOCK = true/' /etc/gitea/app.ini && systemctl restart gitea"

Expected: Gitea restarts with install locked.

Step 3: Verify Gitea web UI is accessible

curl -s -o /dev/null -w "%{http_code}" http://vm-fxhnt-git:3000/

Expected: 200 (or 302 redirect to login page).

Step 4: Create foxhunt organization and repo via API

# Create org
curl -s -X POST http://vm-fxhnt-git:3000/api/v1/orgs \
  -H "Content-Type: application/json" \
  -u "foxhunt-admin:<PASSWORD>" \
  -d '{"username":"foxhunt","full_name":"Foxhunt","visibility":"private"}'

# Create repo in org
curl -s -X POST http://vm-fxhnt-git:3000/api/v1/orgs/foxhunt/repos \
  -H "Content-Type: application/json" \
  -u "foxhunt-admin:<PASSWORD>" \
  -d '{"name":"foxhunt","description":"Foxhunt HFT Trading System","private":true,"default_branch":"main"}'

Expected: Both return 201 Created with JSON responses.

Step 5: Push the foxhunt repo

cd /home/jgrusewski/Work/foxhunt
git remote add gitea git@vm-fxhnt-git:foxhunt/foxhunt.git
git push gitea --all
git push gitea --tags

Expected: All branches and tags pushed. Uses Tailscale SSH via Gitea's built-in SSH server on port 2222 (or system SSH if configured).


Task 6: Detach Public IP

Step 1: Get the public IP ID

scw instance server get $INSTANCE_ID zone=nl-ams-1 | grep -i "public.*ip"

Save the IP ID as $IP_ID.

Step 2: Detach the public IP

scw instance server detach-ip $INSTANCE_ID zone=nl-ams-1

Expected: IP detached.

Step 3: Delete the temporary public IP

scw instance ip delete $IP_ID zone=nl-ams-1

Expected: IP released.

Step 4: Verify still accessible via Tailscale

ssh root@vm-fxhnt-git "hostname"
curl -s -o /dev/null -w "%{http_code}" http://vm-fxhnt-git:3000/

Expected: SSH works, HTTP returns 200/302. No public internet access.


Task 7: Configure DNS Records

The fxhnt.ai zone is now on Scaleway DNS (NS records being migrated from Azure).

Step 1: Add A record for git.fxhnt.ai (Tailscale IP)

Note: This is a private Tailscale IP, so git.fxhnt.ai will only resolve meaningfully for devices on the Tailscale network.

scw dns record add fxhnt.ai \
  name=git \
  type=A \
  data=$TS_IP \
  ttl=300

Expected: A record created.

Step 2: Add a CNAME for the apex if desired

# Optional: point the root domain somewhere useful
scw dns record add fxhnt.ai \
  name="" \
  type=A \
  data=$TS_IP \
  ttl=300

Step 3: Update Gitea config for git.fxhnt.ai domain

ssh root@vm-fxhnt-git "sed -i 's/DOMAIN.*/DOMAIN           = git.fxhnt.ai/' /etc/gitea/app.ini && \
  sed -i 's|ROOT_URL.*|ROOT_URL         = http://git.fxhnt.ai:3000/|' /etc/gitea/app.ini && \
  sed -i 's/SSH_DOMAIN.*/SSH_DOMAIN       = git.fxhnt.ai/' /etc/gitea/app.ini && \
  systemctl restart gitea"

Expected: Gitea now responds to git.fxhnt.ai:3000.

Step 4: Verify DNS propagation

dig @ns0.dom.scw.cloud git.fxhnt.ai A +short

Expected: Returns the Tailscale IP.


Task 8: Remove Old Azure VM from Tailscale

Step 1: Check old node status

tailscale status | grep vm-fxhnt-git

Expected: Should show the new Scaleway node. If the old Azure node is still listed, it needs removal.

Step 2: Remove old node (if still present)

This must be done from the Tailscale admin console (https://login.tailscale.com/admin/machines) or via tailscale CLI with admin privileges. The old Azure node should be removed to avoid hostname conflicts.

Step 3: Delete the temporary security group

scw instance security-group delete $SG_ID zone=nl-ams-1

Expected: Security group removed.


Task 9: Final Verification

Step 1: Verify all access patterns work

# Web UI
curl -s -o /dev/null -w "%{http_code}" http://vm-fxhnt-git:3000/
# or
curl -s -o /dev/null -w "%{http_code}" http://git.fxhnt.ai:3000/

# Git clone via HTTP
git clone http://vm-fxhnt-git:3000/foxhunt/foxhunt.git /tmp/foxhunt-test

# Git clone via SSH (port 2222)
git clone ssh://git@vm-fxhnt-git:2222/foxhunt/foxhunt.git /tmp/foxhunt-test-ssh

# Cleanup
rm -rf /tmp/foxhunt-test /tmp/foxhunt-test-ssh

Expected: All three access methods work.

Step 2: Verify no public access

# Should fail - no public IP
scw instance server get $INSTANCE_ID zone=nl-ams-1 | grep -i public

Expected: No public IP associated.

Step 3: Commit the cloud-init config

cd /home/jgrusewski/Work/foxhunt
git add docs/infra/cloud-init-git-server.yaml docs/plans/2026-02-22-dev-git-server-design.md docs/plans/2026-02-22-dev-git-server-implementation.md
git commit -m "infra: add dev git server cloud-init and design docs"