# How to Run Gitea in a Sandbox and Push to It Over HTTPS

> Run the official gitea/gitea Docker image in a Sandboxer microVM with SQLite and --restart unless-stopped, then host port 3000 privately. Git can't use the ?_token= link, so give git the __sbx_auth cookie with http.extraHeader and use a Gitea access token as the password. Push and clone then work over HTTPS.

Source: https://sandboxer.com/guides/run-gitea-in-a-sandbox. Updated 2026-10-27.

**Short answer:** Run the official gitea/gitea Docker image in a Sandboxer microVM with SQLite and --restart unless-stopped, then host port 3000 privately. Git can't use the ?_token= link, so give git the __sbx_auth cookie with http.extraHeader and use a Gitea access token as the password. Push and clone then work over HTTPS.

## What we ran

Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB RAM, 12 GB disk, $0.018/hour) with Gitea 1.24.7 from the `gitea/gitea:1.24` image and git 2.53 on the laptop side. Tokens are replaced with `<token>`. Times are single runs unless the table says otherwise.

| Step | Result |
| - | - |
| `sbx new --type small` | 3.2 s to ready |
| Pull `gitea/gitea:1.24` and start it | 7.5 s to a passing `/api/healthz` |
| First push (one commit) over the private URL | 0.76 s |
| Mirror push of [expressjs/express](https://github.com/expressjs/express) (10.8 MB pack, 329 branches and tags) | 28.9 s |
| Push a 50 MB commit of random data | 6.1 s and 6.9 s (two runs) |
| Clone express back (6,183 commits) | 6.5 s |
| `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.3 s, `memoryRestored: true`, next push 0.73 s |

## 1. Start Gitea

Docker is preinstalled in every sandbox. We followed Gitea's [Docker install guide](https://docs.gitea.com/installation/install-with-docker) and set the config with its `GITEA__section__KEY` environment variables, so no web installer runs:

```bash
sbx new --name gitea --type small --no-env
id=sb_o4aq9kxinn    # the id that sbx new printed; its subdomain was rich-curious-trout

sbx exec $id --timeout 600 "docker run -d --name gitea --restart unless-stopped \
  -p 3000:3000 -v gitea-data:/data \
  -e GITEA__database__DB_TYPE=sqlite3 \
  -e GITEA__security__INSTALL_LOCK=true \
  -e GITEA__server__ROOT_URL=https://rich-curious-trout-3000.box.sandboxer.com/ \
  -e GITEA__server__DISABLE_SSH=true \
  -e GITEA__service__DISABLE_REGISTRATION=true \
  gitea/gitea:1.24 && until curl -fs localhost:3000/api/healthz >/dev/null; do sleep 0.5; done"
sbx exec $id "docker exec gitea gitea --version"
```

```text
Gitea version 1.24.7 built with GNU Make 4.4.1, go1.24.9 : bindata, timetzdata, sqlite, sqlite_unlock_notify
```

What each setting does here:

- `ROOT_URL` is the hosted URL you get in step 2: `https://<subdomain>-3000.box.sandboxer.com/`. Gitea uses it for the clone URLs it shows and returns from its API. The subdomain is in the `sbx new` output, and it stays the same across stop and resume.
- `INSTALL_LOCK=true` skips the first-run web installer. Without it, the first person to open the URL configures the server.
- `DISABLE_REGISTRATION=true` means only accounts you create exist.
- `DISABLE_SSH=true` hides SSH clone URLs. Everything in this guide uses HTTPS.
- `-v gitea-data:/data` keeps repositories, the SQLite database and config in a named volume on the sandbox disk.

Gitea needs `--timeout 600` on the first `sbx exec` only because of the image pull. `sbx exec` stops a command after 30 seconds by default.

## 2. Create a user and a token, then host the port

Create the admin account and an access token with Gitea's [admin CLI](https://docs.gitea.com/administration/command-line). `--raw` prints only the token, so you can write it straight to a file on your laptop:

```bash
sbx exec $id "docker exec -u git gitea gitea admin user create --admin \
  --username alice --password '<password>' --email alice@example.com --must-change-password=false"
sbx exec $id "docker exec -u git gitea gitea admin user generate-access-token \
  --username alice --token-name laptop --scopes write:repository,write:user --raw" > gitea_token
```

```text
New user 'alice' has been successfully created!
```

Now give port 3000 a URL. [Hosted URLs](/docs/hosting) are private by default:

```bash
sbx host $id 3000 --title Gitea > host.json
python3 -c "import json; print(json.load(open('host.json'))['url'])" | cut -d= -f2 > sbx_token
```

```text
{"ok":true,"type":"port.hosted","sandboxId":"sb_o4aq9kxinn","port":3000,"url":"https://rich-curious-trout-3000.box.sandboxer.com?_token=<token>", …}
```

You now hold two secrets, and they do different jobs:

| Secret | Who checks it | What it protects |
| - | - | - |
| Hosted URL token (`sbx_token`) | The Sandboxer proxy, before Gitea sees anything | Whether a request reaches Gitea at all |
| Gitea access token (`gitea_token`) | Gitea | Which user you are and which repos you can push to |

## 3. Why the private link doesn't work for git

A browser opens the `?_token=` link once, gets a cookie and is in. Git can't do that. It appends `/info/refs?service=…` to the repository URL, and it doesn't keep cookies between commands. We tried three ways from the laptop:

```bash
B=https://rich-curious-trout-3000.box.sandboxer.com
GIT_TERMINAL_PROMPT=0 git ls-remote $B/alice/demo.git
GIT_TERMINAL_PROMPT=0 git ls-remote "$B/alice/demo.git?_token=<token>"
curl -s -w " %{http_code}\n" -H "Cookie: __sbx_auth=$(cat sbx_token)" $B/api/v1/version
```

```text
fatal: could not read Username for 'https://rich-curious-trout-3000.box.sandboxer.com': terminal prompts disabled
fatal: could not read Username for 'https://rich-curious-trout-3000.box.sandboxer.com': terminal prompts disabled
{"version":"1.24.7"}
 200
```

The first two got `401` from the proxy, and git read that as "Gitea wants a password". The token in the query string didn't help. What works is the `__sbx_auth` cookie, which is what the proxy sets after a browser opens the token link. Its value is the URL's token.

## 4. Configure git and push

Git's [`http.<url>.extraHeader`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpextraHeader) sends a header with every request to one URL prefix, so only this server gets the cookie. The Gitea token goes in the [credential store](https://git-scm.com/docs/git-credential-store) as the password:

```bash
git config --global http.$B/.extraHeader "Cookie: __sbx_auth=$(cat sbx_token)"
git config --global credential.helper store
printf 'protocol=https\nhost=rich-curious-trout-3000.box.sandboxer.com\nusername=alice\npassword=%s\n\n' \
  "$(cat gitea_token)" | git credential approve
```

`credential.helper store` keeps the token in plain text in `~/.git-credentials`. Use your OS keychain helper instead if you have one. Either way, the cookie sits in plain text in `~/.gitconfig`, so treat that file like a password too.

Create a private repository through [Gitea's API](https://docs.gitea.com/development/api-usage) (same two credentials) and push:

```bash
curl -s -u "alice:$(cat gitea_token)" -H "Cookie: __sbx_auth=$(cat sbx_token)" \
  -H 'Content-Type: application/json' -d '{"name":"demo","private":true}' $B/api/v1/user/repos
mkdir demo && cd demo && git init -q && echo "# demo" > README.md
git add . && git commit -qm "first commit"
git remote add origin $B/alice/demo.git
git push -u origin main
```

```text
remote: . Processing 1 references
remote: Processed 1 references in total
To https://rich-curious-trout-3000.box.sandboxer.com/alice/demo.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.
```

That push took 0.76 seconds. In a browser, open the full `?_token=` link once, then sign in to Gitea as `alice`.

## 5. Tell the two 401s apart

When a push fails, the message tells you which layer said no. These are the outputs we got:

| What's wrong | git prints |
| - | - |
| No cookie, no stored credentials | `fatal: could not read Username for '…'` |
| No cookie, correct Gitea token stored | `fatal: Authentication failed for '…'` (no `remote:` line) |
| Cookie right, Gitea token wrong | `remote: Failed to authenticate user` then `fatal: Authentication failed` |
| Cookie right, no credentials, private repo | `fatal: could not read Username for '…'` |

A `remote:` line means the request reached Gitea, so the cookie is fine and the Gitea token is the problem. No `remote:` line with a stored token means the proxy rejected the cookie.

Watch out for the second row. After an authentication failure, git tells the credential helper to forget the password it just tried. When our cookie was missing, the proxy's `401` made git erase the correct Gitea token from `~/.git-credentials` (the file was 0 bytes afterwards), and the next push failed even with the cookie fixed. Fix the cookie first, then run the `git credential approve` line from step 4 again.

## 6. A real repository: speed

We mirrored a real project to see how it behaves with many refs and some history:

```bash
git clone -q --bare https://github.com/expressjs/express.git express.git
cd express.git && time git push -q --mirror $B/alice/express.git
```

```text
real	0m28.866s
```

The pack was 10.8 MB, but there were 329 branches and tags, and Gitea processes every pushed ref (the `Processing 329 references` line). A 50 MB commit of random data, one ref, pushed in 6.1 and 6.9 seconds. Cloning express back took 6.5 seconds and had all 6,183 commits and 306 tags.

Downloads depend on your distance from the sandbox. Cloning the 96 MB `demo` repo took 58 seconds from our laptop. The same clone inside the sandbox over `localhost` took 5.4 seconds. A 50 MB download through the hosted URL took 14 to 29 seconds, and the same size from a Hetzner speed-test server in Helsinki took 21 seconds from the same laptop. Our sandbox ran in `eu-central`. So the network path set our clone speed, not the proxy. If a job clones a lot, run it in a sandbox and clone over `localhost:3000`.

## 7. What survives a stop and resume

```bash
sbx stop $id --wait
sbx resume $id
sbx exec $id "docker ps --format '{{.Names}} {{.Status}}'"
```

```text
gitea Up About a minute
```

Stop took 2.2 seconds and resume 1.3 seconds. The resume reported `memoryRestored: true`, so Gitea never restarted. The hosted URL and its token were unchanged, the old cookie still worked, and the next push took 0.73 seconds.

A resume boots from disk instead when no machine with the same CPU type is free, or when you resize. Then the `gitea-data` volume is there and the restart policy starts Gitea again. We didn't force that here. Restarting the Docker daemon shows the same policy at work:

```bash
sbx exec $id --timeout 120 "sudo systemctl restart docker
until curl -fs localhost:3000/api/healthz >/dev/null; do sleep 0.5; done
docker ps --format '{{.Names}} {{.Status}}'; docker exec gitea ls /data/git/repositories/alice"
```

```text
gitea Up Less than a second
demo.git
express.git
```

## 8. Public instead of private

`sbx host $id 3000 --public` drops the proxy's check, and Gitea's own login is the only gate:

| Request with a public URL | Result |
| - | - |
| `curl $B/api/v1/version`, anonymous | `200`, `{"version":"1.24.7"}` |
| `git ls-remote` of the private repo, anonymous | `fatal: could not read Username` (Gitea asked for credentials) |
| Same, with `alice` and the Gitea token | Lists `HEAD` |

Public is simpler for CI runners and teammates: one credential, no cookie. It also exposes Gitea's login page to anyone who finds the URL. Switching back with `sbx host $id 3000 --private` minted a new token: the old cookie got `401` and the new one `200`. Update `http.<url>.extraHeader` on every machine after a switch.

## 9. Clean up

```bash
sbx delete $id --yes
```

A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped. Remove the `extraHeader` line and the stored credential from your laptop when you're done.

## FAQ

### Can I clone with the private `?_token=` link?

No. In our test, `git ls-remote` with `?_token=` on the URL got the proxy's `401`, because git adds its own path and query after the URL. Send the token as the `__sbx_auth` cookie with `http.<url>.extraHeader` instead.

### Why did git ask for a username when my Gitea token was fine?

The proxy answered `401` before the request reached Gitea, and git treats any `401` as a password prompt. Check that the cookie is set for the exact URL prefix and that you didn't switch the port public and back, which changes the token.

### Can I push over SSH instead?

We didn't test it. Gitea's SSH server listens inside the container, and a sandbox's SSH endpoint is the sandbox's own `sshd`, not Gitea. This guide sets `DISABLE_SSH=true` and uses HTTPS only.

### Will my repositories survive if the sandbox auto-stops?

Yes. Auto-stop is a normal stop: disk and memory are saved, and `sbx resume` brings Gitea back. The repos live in the `gitea-data` volume, and `--restart unless-stopped` covers the times a resume boots from disk.

### Do I need a bigger size than `small`?

Not for this. Gitea with SQLite handled a 10.8 MB mirror and two 50 MB pushes on `small`. Moving to a smaller size isn't supported yet (you'd create a new sandbox and copy), so start small and resize up on resume if you need to.

## Related

- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Turn a GitHub issue into a pull request from a sandbox](/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox)
- [Run MySQL or MariaDB in a sandbox](/guides/run-mysql-or-mariadb-in-a-sandbox)
- [Hosting](/docs/hosting)
