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>
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"