Skip to content

# Dev Environment — CT 104 (dev.nuclide.systems)

Migrated from CT 111 to CT 104 on 2026-05-26. CT 111 ("dev") is decommissioned; LXC pending removal.

CT 104 hosts the self-hosted development platform: Coder (dev workspaces) + Gitea (internal repos).

## Services

Service | URL | Port
--- | --- | ---
Coder | https://dev.nuclide.systems | 7080
Gitea | https://git.nuclide.systems | 3000

Both use Pocket-ID OIDC (`https://id.nuclide.systems`) for SSO.

## Coder — Dev Workspaces

### What it is

Coder provisions isolated Docker-based dev environments (workspaces) on CT 111. Each workspace has: - A full Linux environment with your tools - Persistent home dir on UNAS (`/mnt/pve/unas/services/coder/`) - Intel Arc GPU renderD128 available - VS Code Server (browser or desktop SSH tunnel)

### First-time setup

1. Go to `https://dev.nuclide.systems` → log in via Pocket-ID
2. Create a workspace from a template (admin must create templates first)
3. Connect via VS Code: install Coder extension → sign in → open workspace

### VS Code connection

    # Install Coder CLI on your local machine
    curl -fsSL https://coder.com/install.sh | sh

    # Authenticate
    coder login https://dev.nuclide.systems

    # Open workspace in VS Code
    coder open <workspace-name>

Or use the Coder VS Code extension directly from the marketplace (`coder.coder-remote`).

### Claude Code inside a workspace

    # Inside the workspace terminal
    npm install -g @anthropic/claude-code
    claude

Claude Code runs inside the workspace container — same environment, same files, same GPU.

### Coder MCP (AI agent sandbox execution)

Add to Claude Code's MCP config (`~/.claude/claude_desktop_config.json` or via `/mcp add`):

    {
      "mcpServers": {
        "coder": {
          "command": "coder",
          "args": ["mcp", "server"],
          "env": {
            "CODER_URL": "https://dev.nuclide.systems",
            "CODER_TOKEN": "<your-api-token>"
          }
        }
      }
    }

Claude can then create workspaces, execute code, and read output via MCP tools: - `coder_list_workspaces` - `coder_create_workspace` - `coder_execute_command` ← sandbox code execution - `coder_start_workspace` / `coder_stop_workspace`

Get your API token: `coder tokens create`

### Creating workspace templates

Templates are Terraform configs stored in Gitea. Basic Docker template:

    coder templates push <template-name> --directory ./template/

## Gitea — Internal Repos

### What it is

Self-hosted Git for internal infrastructure: compose files, CT configs, dotfiles, Coder templates. Not the primary remote for Claude Code collaboration — use GitHub for that.

### First-time setup (admin)

1. Go to `https://git.nuclide.systems` → complete installation wizard
2. Set admin account, confirm DB settings (pre-filled from env)
3. Add OIDC provider: Admin → Site Administration → Authentication Sources
4. Auth type: OAuth2
5. Provider: OpenID Connect
6. Discovery URL: `https://id.nuclide.systems/.well-known/openid-configuration`
7. Client ID/Secret: create a new client in Pocket-ID for Gitea

### SSH access

    # Gitea SSH runs on port 222
    git clone ssh://git@git.nuclide.systems:222/<user>/<repo>.git

    # Or add to ~/.ssh/config:
    Host git.nuclide.systems
        Port 222
        IdentityFile ~/.ssh/id_ed25519

### Recommended repos to create

Repo | Contents
--- | ---
`infra/proxmox` | `/etc/pve/` snapshots, CT configs
`infra/stacks` | Compose files from CT 104/101/111
`infra/docs` | Mirror of `/docs/` on Proxmox host
`dev/templates` | Coder workspace Terraform templates

## Storage layout (UNAS)

    /mnt/pve/unas/services/
    ├── coder/          # Coder workspace home dirs (persistent)
    └── gitea/          # Gitea repos + data

## CT 104 specs (current host)

IP | 192.168.1.40
Cores | 16
RAM | 48GB
Rootfs | 200GB local-zfs
UNAS | /mnt/pve/unas (mp0) — coder + gitea data paths unchanged
GPU | renderD128 (Intel Arc Xe, idmapped)

Compose files at `/opt/stacks/coder/` and `/opt/stacks/gitea/` on CT 104. Gitea uses Redis (`gitea-redis`) for queue/cache/session — required because CT 104 uses idmapped NFS which doesn't support LevelDB file locks. Act-runner at `/opt/stacks/act-runner/` — runner name `ct104-runner`.

## OIDC clients in Pocket-ID

Client | Callback URL
--- | ---
Coder | `https://dev.nuclide.systems/api/v2/users/oidc/callback`
Gitea | Add via Gitea admin UI (see above)

To create new OIDC clients programmatically, see `/docs/proxmox-optimizations.md` § OIDC client creation via SQLite.

## Auth lockdown (SSO-only)

Both Gitea and Coder are locked to Pocket-ID OIDC only. Local password and GitHub login are disabled. Passkey/WebAuthn login to Gitea remains available because it's tied to OIDC accounts, not to a separate password.

### Compose env flags

Coder (`/opt/stacks/coder/compose.yaml`):

    CODER_OIDC_ISSUER_URL: "https://id.nuclide.systems"
    CODER_OIDC_CLIENT_ID: "${CODER_OIDC_CLIENT_ID}"
    CODER_OIDC_CLIENT_SECRET: "${CODER_OIDC_CLIENT_SECRET}"
    CODER_OIDC_ALLOW_SIGNUPS: "true"
    CODER_OIDC_EMAIL_DOMAIN: "nucli.de"
    CODER_DISABLE_PASSWORD_AUTH: "true"
    CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE: "false"

Note: the bundled "GitHub external auth provider" log line is for workspaces cloning from GitHub, not for login — leaving it on is fine.

Gitea (`/opt/stacks/gitea/compose.yaml`):

    GITEA__oauth2__ENABLED: "true"
    GITEA__openid__ENABLE_OPENID_SIGNIN: "false"        # legacy OpenID 2.0 button off
    GITEA__openid__ENABLE_OPENID_SIGNUP: "false"
    GITEA__service__ENABLE_PASSWORD_SIGNIN_FORM: "false" # local username/password form off
    GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "true"
    GITEA__oauth2_client__ACCOUNT_LINKING: "auto"
    GITEA__oauth2_client__USERNAME: "preferred_username"
    GITEA__oauth2_client__UPDATE_AVATAR: "true"

Note: `ENABLE_OPENID_SIGNIN` lives in `[openid]`, not `[service]`. Putting it under `GITEA__service__` is a no-op and leaves the legacy button visible.

Gitea login source (DB row in `login_source`): - `name = "pocket-id"` (case-sensitive — becomes part of the callback URL) - `Scopes = ["openid","profile","email"]` - `two_factor_policy = "skip"` (Pocket-ID passkey already enforces 2FA)

### Pocket-ID client secret pitfall (important)

Pocket-ID 2.7.0 verifies client secrets with bcrypt (`bcrypt.CompareHashAndPassword`). The stored `oidc_clients.secret` column must be a 60-char bcrypt hash like `$2a$10$...` or `$2b$10$...`.

A raw 64-char hex SHA-256 in that column silently fails every token exchange with `invalid client secret`. The Gitea and Coder clients on this host were initially provisioned that way and had to be regenerated.

To programmatically add or rotate an OIDC client secret in Pocket-ID:

    # On Proxmox host (has python3-bcrypt installed)
    python3 <<'EOF'
    import bcrypt, secrets, string
    alphabet = string.ascii_letters + string.digits
    plain = "".join(secrets.choice(alphabet) for _ in range(40))
    hashed = bcrypt.hashpw(plain.encode(), bcrypt.gensalt(rounds=10)).decode()
    print("PLAINTEXT (give to client app):", plain)
    print("HASH (store in oidc_clients.secret):", hashed)
    EOF

Always push the hash to Pocket-ID via a tmp file (`pct push 110 ...`) — never inline the bcrypt hash in a shell command, the `$` chars get expanded.

To verify a client's secret is the right format:

    pct exec 110 -- sqlite3 /opt/stacks/pocketid/data/pocket-id.db \
      "SELECT name, length(secret), substr(secret,1,7) FROM oidc_clients;"
    # Working clients: length=60, prefix "$2a$10$" or "$2b$10$"
    # Broken clients : length=64, prefix is hex (e.g. "1180664")

### Break-glass recovery (if SSO is broken)

You have host SSH access, so you're never locked out — but the UI will be unusable until you re-enable a local login path. From the Proxmox host:

Gitea — re-enable local login + reset password:

    # 1. Temporarily put the form back
    ssh nuc
    python3 -c "
    p='/rpool/data/subvol-111-disk-0/opt/stacks/gitea/compose.yaml'
    s=open(p).read().replace('ENABLE_PASSWORD_SIGNIN_FORM: \"false\"','ENABLE_PASSWORD_SIGNIN_FORM: \"true\"')
    open(p,'w').write(s)"
    pct exec 111 -- bash -c 'cd /opt/stacks/gitea && docker compose up -d --force-recreate gitea'

    # 2. Reset admin password
    pct exec 111 -- docker exec -u git gitea gitea -c /data/gitea/conf/app.ini admin user change-password -u fkrebs -p 'temp-strong-pass'

Coder — flip user back to password login + re-enable:

    ssh nuc
    # 1. Flip login_type back
    pct exec 111 -- docker exec coder-db psql -U coder -d coder -c \
      "UPDATE users SET login_type='password' WHERE username='fkrebs';"

    # 2. Re-enable password auth in compose
    python3 -c "
    p='/rpool/data/subvol-111-disk-0/opt/stacks/coder/compose.yaml'
    s=open(p).read().replace('CODER_DISABLE_PASSWORD_AUTH: \"true\"','CODER_DISABLE_PASSWORD_AUTH: \"false\"')
    open(p,'w').write(s)"
    pct exec 111 -- bash -c 'cd /opt/stacks/coder && docker compose up -d --force-recreate coder'

    # 3. Reset password (interactive)
    pct exec 111 -- docker exec -it coder coder reset-password fkrebs

Pocket-ID — bootstrap a one-time access token if you're locked out of Pocket-ID itself:

    pct exec 110 -- docker exec pocket-id pocket-id-cli one-time-access-token \
      --email fkrebs@nucli.de --duration 1h
    # Open the printed URL in a browser to log in once and register a new passkey.

After SSO is fixed: undo each of the above steps (re-disable password auth, flip `login_type` back to `oidc`).