Can I self-host GitLab?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called Gitea. It takes one prompt, a 1024 MB VPS, and about 10 minutes. That is $29 a month you stop paying GitLab — $348 a year on the Premium plan, 1 seat assumed.

Why people pay for GitLab

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

GitLab sells the whole software delivery surface: Git hosting, CI, packages, security scanners and permissions in one product, billed per seat so engineering headcount is the invoice. People pay to avoid assembling that toolchain themselves.

GitLab plans and list prices
PlanList priceWhat it buys
FreefreeFree SaaS tier with limits.
Premiumthe plan this page prices against$29/mo per seatList price $29 per user per month billed annually on about.gitlab.com/pricing (read 2026-08-07).
Ultimatequote onlySales-led on the live pricing page: a custom-pricing contact path, not a number. $99 per user per month is the widely published list figure; treat it as unconfirmed until sales quotes you.

Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: high

Replaced by Gitea

One project, named before the prompt, so you know what you are about to install.

A lightweight self-hosted Git service with issues, PRs and package registries you run yourself.

A lightweight self-hosted forge for repos, issues and PRs. One container with SQLite for personal scale; not a feature clone of GitLab Premium CI and security scanners.

The swap

You're paying

GitLab

$29/mo · $348/yr

is replaced by

You'd run

Gitea

ONE COMMAND · ~10 min to running · 1024 MB RAM

GitLab Premium · 1 seat assumed · vendor list price · checked 2026-08-07 · source

Before you start

RAM floor
1024 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~10 minunder 10 minutes, through the first backup

The prompt

Two paths to the same Gitea: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

296 lines · 12,984 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Gitea 1.27.1 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. Say three things when you ask. One: that
hostname becomes `GITEA__server__DOMAIN` and `GITEA__server__ROOT_URL`, and clone URLs and
redirects are wrong if they disagree with the browser. Two: the first account created in the
install wizard is the admin (first claimant), so the user must open the URL and finish that
before anyone else can. Three: this install does not publish git over SSH; pushes and pulls go
over HTTPS with a personal access token unless they later ask to open SSH on purpose.

Gitea needs 1024 MB of RAM available and 10 GB free on /srv. The 1.27.1 image publishes amd64
and arm64. Measure:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 1024 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a hostname that does not resolve, and ROOT_URL would point at a name that does not answer.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/gitea /srv/gitea/backups /srv/gitea/data
ls -la /srv/gitea
```

Assert: `ls -la` shows `backups` and `data` owned by the login user. `data` is the whole product
state: git repositories, the SQLite file, attachments, avatars, LFS objects and `app.ini`. The
image runs as USER_UID/GID 1000 and will chown what it needs under `/data` on first start.

## 3. Secrets

No secret is generated for this install and there is no `.env` file. That is not an oversight.
Gitea's install wizard creates the administrator account on first visit; the password is chosen
in the browser and never passes through this prompt. `secretsToGenerate` is zero. Step 7 is
where the open registration door closes after that first account exists.

What replaces a generated credential here is speed: anyone who can reach https://<DOMAIN>/ before
the user finishes the wizard can claim the instance. Say that plainly, then move to compose so
the window stays short.

## 4. compose.yml

Write the file, then substitute the real hostname into `DOMAIN` and `ROOT_URL`.

```bash
cat > /srv/gitea/compose.yml <<'EOF'
# Gitea · the compose file for this service. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker ............. https://docs.gitea.com/installation/install-with-docker
#   config cheat sheet . https://docs.gitea.com/administration/config-cheat-sheet
#   reverse proxy ...... https://docs.gitea.com/administration/reverse-proxies
#
# One service. SQLite lives under /data (no separate database container).
# ROOT_URL and DOMAIN must match the public hostname or clone links and
# redirects will be wrong. SSH is not published on this host: git over HTTPS
# with a personal access token is the path this install documents. After the
# first account is created, set GITEA__service__DISABLE_REGISTRATION=true and
# restart so the open signup door closes. Digest read from Docker Hub on
# 2026-08-07 for tag 1.27.1.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  gitea:
    image: gitea/gitea:1.27.1@sha256:34e3f6b75f5cbb6aebce588037fc5a53c84213e4d4b00da0a8d73e031a558e52
    container_name: gitea
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__database__DB_TYPE: sqlite3
      GITEA__server__DOMAIN: <DOMAIN>
      GITEA__server__ROOT_URL: https://<DOMAIN>/
      GITEA__server__DISABLE_SSH: "true"
      GITEA__server__START_SSH_SERVER: "false"
      # Flip to "true" after the first account claims the instance (step 7).
      GITEA__service__DISABLE_REGISTRATION: "false"
    volumes:
      # Repos, SQLite, attachments, avatars, app.ini.
      - /srv/gitea/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8208.
      # No SSH port is published.
      - "127.0.0.1:8208:3000"
EOF
DOMAIN_HOST=<DOMAIN>
sed -i "s|<DOMAIN>|${DOMAIN_HOST}|g" /srv/gitea/compose.yml
cd /srv/gitea && docker compose config >/dev/null && echo "compose OK"
```

Set `DOMAIN_HOST` to the real hostname from step 1 before `sed`. Assert: that prints
`compose OK`. One service, one published port, no database container, no SSH port. Do not add a
Caddy service to this file.

## 5. Caddy and TLS

Write the site block under `/srv/gitea/Caddyfile`, then append it to the live Caddyfile with
`<DOMAIN>` replaced. Copy the live file first: a syntax error here takes down every other site
on the box.

```bash
cat > /srv/gitea/Caddyfile <<'EOF'
# Gitea · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.gitea.com/administration/reverse-proxies and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8208 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Git over HTTPS uses
	# this same reverse_proxy; no SSH port is published by this install.
	reverse_proxy 127.0.0.1:8208
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-gitea
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
DOMAIN_HOST=<DOMAIN>
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" /srv/gitea/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
`/etc/caddy/Caddyfile.before-gitea`, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own.

## 6. Firewall

Two ports open, both Caddy's. These are idempotent:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in for both
the web UI and git over HTTPS, and 443/udp is HTTP/3. 8208 stays closed because compose binds it
to 127.0.0.1. Do not open 22 for Gitea and do not publish container port 22: SSH git is out of
scope for this install. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp
and 443/udp, and no rule mentioning 8208 or 3000.

## 7. Start and verify

```bash
cd /srv/gitea
docker compose pull
docker compose up -d
for i in $(seq 1 36); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; case "$code" in 200|301|302|303|307|308) break ;; esac; sleep 5; done
curl -sSL https://<DOMAIN>/ | grep -ciE 'gitea|install|register|sign'
docker compose ps
```

Assert: the loop ends with a 2xx or 3xx status, and the body mentions Gitea or the install /
sign-in surface (count greater than `0`). If Caddy returns 502 with a running container, step 5
is the likely cause. If the container restarts, check
`docker compose logs --tail 40 gitea` and that `data/` is writable for UID 1000. A running
container is not success.

STOP: tell the user to open https://<DOMAIN>/ now, finish the install wizard if it appears, and
create the first administrator account with a password they will keep. They must confirm back to
you that they are signed in as that admin before you continue. Do not continue until they confirm.

After they confirm, close registration and assert it is closed:

```bash
cd /srv/gitea
sed -i 's/GITEA__service__DISABLE_REGISTRATION: "false"/GITEA__service__DISABLE_REGISTRATION: "true"/' compose.yml
docker compose up -d
sleep 8
echo -n 'disable_flag_count='; grep -c 'DISABLE_REGISTRATION: "true"' /srv/gitea/compose.yml
curl -sS -o /tmp/gitea-signup.html -w 'signup_status=%{http_code}\n' https://<DOMAIN>/user/sign_up
# Body without following redirects (shows 302/303 away from an open form when closed).
# Body following redirects (should not be a filled registration form).
curl -sSL https://<DOMAIN>/user/sign_up -o /tmp/gitea-signup-followed.html
echo -n 'open_form_markers='; grep -ciE 'name="user_name"|id="user_name"' /tmp/gitea-signup-followed.html
echo -n 'disabled_or_login_markers='; grep -ciE 'registration is disabled|Forbidden|sign.in|Sign In|log.in|Log In' /tmp/gitea-signup-followed.html
```

Assert: `disable_flag_count` is `1`. Print `signup_status` and both marker counts. An open
registration form after disable is a failure: `open_form_markers` must be `0` (no username field
on an anonymous signup page). If `open_form_markers` is greater than `0`, stop and fix before
the handoff. If you cannot show evidence signup is closed, do not proceed.

Then show the first clone/push handoff (the product's core loop). Tell the user to create an
empty repository in the UI, open Settings → Applications → Generate New Token with `write:repository`
scope (or the current equivalent for HTTPS git), then on their laptop:

```bash
git clone https://<DOMAIN>/<username>/<repo>.git
cd <repo>
echo '# hello' > README.md
git add README.md
git commit -m "first commit"
git push -u origin main
```

When git asks for a password, they paste the token, not the account password. Username is their
Gitea username. SSH clone URLs are not configured on this host; if the UI shows an SSH remote,
tell them to use the HTTPS remote instead.

STOP: do not continue until they confirm a successful push (or an explicit decision to skip the
push until they have a repo ready). Do not continue until they confirm.

## 8. First backup and restore

One archive: the data volume (repos, SQLite, attachments), the compose file, and the live Caddy
site block. There is no `.env`. Take a backup now and again after the first real repositories
land.

```bash
cd /srv/gitea
docker compose stop
sudo tar -czf /srv/gitea/backups/gitea-$(date +%F).tar.gz \
  -C /srv/gitea data compose.yml \
  -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/gitea/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container is stopped on purpose
so SQLite and loose objects are not half-written. Never append `|| true` to this tar.

A backup on the same disk as the data is not a backup. From the user's machine:

```bash
mkdir -p ~/backups/gitea
scp vps:/srv/gitea/backups/*.tar.gz ~/backups/gitea/
```

To restore: `cd /srv/gitea`, `docker compose down`, move aside `data`, untar into `/srv/gitea`
(and restore `/etc/caddy/Caddyfile` if needed), then `docker compose up -d`. Tell the user:
`data/` is every repository and the account database. Losing it costs the forge.

## 9. Updating later

New versions are listed at https://github.com/go-gitea/gitea/releases. Read the release notes for
breaking changes before jumping versions. Take a backup first, then edit the image line in
`/srv/gitea/compose.yml` to the new tag and digest:

```bash
cd /srv/gitea
docker compose pull
docker compose up -d
docker compose logs --tail 40 gitea
```

Confirm you can sign in and that `DISABLE_REGISTRATION` is still `"true"` after the upgrade.
Re-check https://<DOMAIN>/user/sign_up still does not present an open form.

## 10. What will probably go wrong

You will clone with an SSH remote out of habit, get "connection refused" on port 22, and
assume Gitea is down. It is not. This install never published SSH. Switch the remote to HTTPS,
use a personal access token as the password, and the push works. The second failure mode is
forgetting to flip `DISABLE_REGISTRATION` after the first account: the forge stays a public
signup form until you do. The third is `ROOT_URL` wrong after a rename: clone links and OAuth
callbacks point at the old name until you fix compose and recreate the container.

## 11. Out of scope

- Do not add a Caddy container to the compose file. Caddy already runs under systemd on this box.
- Do not publish 8208 on `0.0.0.0` or open it in the firewall.
- Do not publish git over SSH or open port 22 for Gitea unless the user explicitly asks later.
- Do not add PostgreSQL, Actions runners, or a second forge container in this install.
- Do not skip closing registration or the signup assert after the first account.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Gitea 1.27.1 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the
box.

Read these before step 1. The hostname becomes `GITEA__server__DOMAIN` and
`GITEA__server__ROOT_URL`; clone links and redirects are wrong if they disagree with the browser.
The first account created in the install wizard is the admin (first claimant), so open the URL
and finish that before anyone else can. This install does not publish git over SSH; pushes and
pulls use HTTPS with a personal access token. After the first account, you will set
`GITEA__service__DISABLE_REGISTRATION=true` and prove the signup form is gone.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `1024` MB available, at least `10` G free, `amd64` or `arm64`, and your
server's IP. If dig is empty, add the A record and wait. Caddy cannot certify a name that does
not resolve, and ROOT_URL would point at nothing useful.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/gitea /srv/gitea/backups /srv/gitea/data
ls -la /srv/gitea
```

You should see `backups` and `data` under `/srv/gitea`. `data` is the whole product: git repos,
SQLite, attachments, avatars, LFS objects and `app.ini`. The container runs as UID/GID 1000.

## 3. Secrets

No secret is generated and there is no `.env` file. The wizard creates the administrator password
in the browser. What matters is speed: anyone who reaches https://<DOMAIN>/ before you finish the
wizard can claim the instance. Complete step 7 as soon as the stack answers.

## 4. compose.yml

Paste the whole block, then substitute the hostname.

```bash
cat > /srv/gitea/compose.yml <<'EOF'
# Gitea · the compose file for this service. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker ............. https://docs.gitea.com/installation/install-with-docker
#   config cheat sheet . https://docs.gitea.com/administration/config-cheat-sheet
#   reverse proxy ...... https://docs.gitea.com/administration/reverse-proxies
#
# One service. SQLite lives under /data (no separate database container).
# ROOT_URL and DOMAIN must match the public hostname or clone links and
# redirects will be wrong. SSH is not published on this host: git over HTTPS
# with a personal access token is the path this install documents. After the
# first account is created, set GITEA__service__DISABLE_REGISTRATION=true and
# restart so the open signup door closes. Digest read from Docker Hub on
# 2026-08-07 for tag 1.27.1.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  gitea:
    image: gitea/gitea:1.27.1@sha256:34e3f6b75f5cbb6aebce588037fc5a53c84213e4d4b00da0a8d73e031a558e52
    container_name: gitea
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__database__DB_TYPE: sqlite3
      GITEA__server__DOMAIN: <DOMAIN>
      GITEA__server__ROOT_URL: https://<DOMAIN>/
      GITEA__server__DISABLE_SSH: "true"
      GITEA__server__START_SSH_SERVER: "false"
      # Flip to "true" after the first account claims the instance (step 7).
      GITEA__service__DISABLE_REGISTRATION: "false"
    volumes:
      # Repos, SQLite, attachments, avatars, app.ini.
      - /srv/gitea/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8208.
      # No SSH port is published.
      - "127.0.0.1:8208:3000"
EOF
DOMAIN_HOST=<DOMAIN>
sed -i "s|<DOMAIN>|${DOMAIN_HOST}|g" /srv/gitea/compose.yml
cd /srv/gitea && docker compose config >/dev/null && echo "compose OK"
```

Set `DOMAIN_HOST` to your real hostname before sed. You should see `compose OK`. One service, no
SSH port, no database container. Do not add a Caddy service here.

## 5. Caddy and TLS

```bash
cat > /srv/gitea/Caddyfile <<'EOF'
# Gitea · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.gitea.com/administration/reverse-proxies and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8208 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Git over HTTPS uses
	# this same reverse_proxy; no SSH port is published by this install.
	reverse_proxy 127.0.0.1:8208
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-gitea
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
DOMAIN_HOST=<DOMAIN>
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" /srv/gitea/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

`caddy validate` and reload must both exit 0. If validate fails, restore
`/etc/caddy/Caddyfile.before-gitea`, reload, and fix the syntax. Caddy obtains and renews the
certificate on its own.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see Status active, 80 and 443 open, nothing for 8208 or 3000. Do not open a Gitea SSH
port. HTTPS is how git travels on this install.

## 7. Start and verify

```bash
cd /srv/gitea
docker compose pull
docker compose up -d
for i in $(seq 1 36); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; case "$code" in 200|301|302|303|307|308) break ;; esac; sleep 5; done
curl -sSL https://<DOMAIN>/ | grep -ciE 'gitea|install|register|sign'
docker compose ps
```

You should see a 2xx/3xx loop end and a body that looks like Gitea or its install surface. If
you get 502, re-check step 5. If the container restarts, read
`docker compose logs --tail 40 gitea` and check that `data/` is usable for UID 1000.

STOP: open https://<DOMAIN>/ now, finish the install wizard if it appears, create the first
administrator account, and confirm you are signed in as that admin. Do not continue until that is done.

Close registration after that account exists:

```bash
cd /srv/gitea
sed -i 's/GITEA__service__DISABLE_REGISTRATION: "false"/GITEA__service__DISABLE_REGISTRATION: "true"/' compose.yml
docker compose up -d
sleep 8
echo -n 'disable_flag_count='; grep -c 'DISABLE_REGISTRATION: "true"' /srv/gitea/compose.yml
curl -sS -o /tmp/gitea-signup.html -w 'signup_status=%{http_code}\n' https://<DOMAIN>/user/sign_up
curl -sSL https://<DOMAIN>/user/sign_up -o /tmp/gitea-signup-followed.html
echo -n 'open_form_markers='; grep -ciE 'name="user_name"|id="user_name"' /tmp/gitea-signup-followed.html
```

`disable_flag_count` must be `1`. `open_form_markers` must be `0` (no open registration form for
anonymous visitors). If the form is still open, stop and fix before the handoff.

First clone/push handoff (core loop). Create an empty repository in the UI. Create a personal
access token under Settings with repository write scope. On your laptop:

```bash
git clone https://<DOMAIN>/<username>/<repo>.git
cd <repo>
echo '# hello' > README.md
git add README.md
git commit -m "first commit"
git push -u origin main
```

When git asks for a password, paste the token, not the account password. Username is your Gitea
username. Ignore SSH remotes the UI may show; SSH is not published here.

STOP: confirm a successful push, or explicitly defer the push until a repo is ready. Do not continue until that is done.

## 8. First backup and restore

Archive `data/` (the real state), `compose.yml`, and the live Caddyfile.

```bash
cd /srv/gitea
docker compose stop
sudo tar -czf /srv/gitea/backups/gitea-$(date +%F).tar.gz \
  -C /srv/gitea data compose.yml \
  -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/gitea/backups/
```

The archive must exist and be non-empty; print its size. Do not append `|| true` to tar. From
your laptop:

```bash
mkdir -p ~/backups/gitea
scp vps:/srv/gitea/backups/*.tar.gz ~/backups/gitea/
```

To restore: `cd /srv/gitea`, `docker compose down`, move aside `data`, untar into `/srv/gitea`
(restore Caddy if needed), `docker compose up -d`. Losing `data/` loses every repository and the
account database. Take another backup after the first real projects land.

## 9. Updating later

Releases: https://github.com/go-gitea/gitea/releases. Read notes before jumping versions. Backup
first, edit the image line in `/srv/gitea/compose.yml` to the new tag and digest:

```bash
cd /srv/gitea
docker compose pull
docker compose up -d
docker compose logs --tail 40 gitea
```

Confirm you can sign in and that `DISABLE_REGISTRATION` is still `"true"`. Re-check
`/user/sign_up` still does not present an open form.

When you pin a new digest, record it next to the release tag. If an upgrade crash-loops, roll the
image line back, bring the stack up, then read migration logs. Do not run two Gitea containers
against the same `data/` directory.

## 10. What will probably go wrong

You will clone with an SSH remote out of habit, get connection refused on port 22, and assume
Gitea is down. It is not. This install never published SSH. Switch to HTTPS, use a personal
access token as the password, and the push works. Second: forgetting to flip
`DISABLE_REGISTRATION` after the first account leaves a public signup form. Third: wrong
`ROOT_URL` after a rename breaks clone links until you fix compose and recreate. Fourth: disk
full under `/srv/gitea/data` fails every push; watch free space before large binaries.

## 11. Out of scope

- Do not add a Caddy container to compose. Caddy already runs under systemd on this host.
- Do not publish 8208 on `0.0.0.0` or open it in the firewall.
- Do not publish git over SSH or open port 22 for Gitea unless you explicitly decide later.
- Do not add PostgreSQL, Actions runners, or a second forge in this install.
- Do not skip closing registration or the signup assert after the first account.

Hostname discipline: DOMAIN, ROOT_URL, the Caddy site name and the browser URL must match.
Security discipline: claim the admin account immediately, then disable registration and prove it.
State discipline: `/srv/gitea/data` is the product; backups that skip it are not backups of the
forge. Transport discipline: HTTPS + token is the supported git path on this pin.

NOT YET VERIFIED: no harness run has been recorded against this install path.

If a step's assert fails, name the earlier step that most likely caused it before changing
anything else. Preflight failures are step 1. Wrong ROOT_URL or DOMAIN after a rename is step 4.
Certificate or 502 problems are step 5. Open ports that should be closed are step 6. An open
signup form after the first account is step 7 incomplete. Empty backups are step 8.

Claim race detail: between `docker compose up -d` and the moment you submit the admin form, the
instance is first-come-first-served. On a public hostname that window is the real risk. Do not
leave the browser tab open "for later" while DNS propagates to friends.

Token discipline: treat personal access tokens like passwords. Store them in a password manager.
Revoke tokens you no longer use. Never put a token in a shell history you will paste into chat.
For CI machines, prefer deploy keys or limited tokens over the admin password.

Backup cadence: empty-initialized backup after install, second backup after the first real
repositories, then on a schedule you will actually keep. Off-box copies matter more than clever
retention scripts you never run.

Upgrade discipline: read release notes when crossing minor versions. SQLite migrations usually
run on start; watch logs until they settle. If you later outgrow SQLite, that is a planned
migration to Postgres with its own backup story, not a silent compose tweak mid-week.

Forgejo note for context only: a community hard fork of Gitea exists (Codeberg e.V., hard fork
from early 2024, active 2026 releases, license shift toward GPL for new work). This page installs
Gitea. Do not swap the image for Forgejo in this prompt without a separate plan.

This path is NOT YET VERIFIED on a clean harness machine; treat the asserts as the contract and
stop when they fail.

Git credential helpers on the laptop can store the token after the first push so you are not
pasting it every time. That is fine on a personal machine; on a shared computer, prefer one-shot
auth and clear the helper after. Submodules and LFS both need working HTTPS credentials too; if
LFS objects fail to push, check free disk on the server under /srv/gitea/data before blaming the
token.

Webhooks and Actions runners are out of scope for this install. If you enable Actions later, that
is a second service with its own secrets and network surface. Packages (container or language
registries) also grow disk quickly; budget them separately from source history.

Email for registration and notifications is not configured here. Password reset by email will not
work until you add SMTP yourself. Keep the admin password somewhere recoverable without email.

Time and timezone: the container uses the image default. If commit timestamps look wrong, set TZ
deliberately later rather than guessing. Mirror and migration tools from GitHub/GitLab can import
history; run them after registration is closed so imported users do not land on an open instance.

When something fails, collect four facts before changing config: `docker compose ps`, the last
forty log lines, `curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/`, and free disk on
/srv. Most "Gitea is broken" reports are one of those four.

Keep the off-box backup current before every upgrade.

236 lines · 9,766 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Gitea 1.27.1 under ~/selfhost/gitea, answering at http://localhost:8208.

## 1. Preflight

Say this to the user before step 2 runs. Git remotes that point at localhost only work on this
computer. A laptop and a desktop will not share the same origin without a real hostname later.
This path still teaches the forge loop: create an account, close registration, push over HTTPS
with a token. SSH is not published here either.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. Gitea needs
1024 MB of RAM available and 10 GB free on the home disk, and the image publishes amd64 and
arm64. If available RAM is under 1024 MB or free disk is under 10 GB, print both numbers and
stop. Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running. Do not continue until they confirm.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/gitea/data ~/selfhost/gitea/backups
ls -la ~/selfhost/gitea
```

Assert: `data` and `backups` exist. `data` holds repos, SQLite, attachments and `app.ini`.

## 4. Secrets

No secret is generated and there is no `.env` file. The install wizard creates the admin account
in the browser. The risk on a public host does not apply the same way on loopback, but
registration still closes after the first account so a later bind change cannot leave an open
signup form.

## 5. compose.yml

```bash
cat > ~/selfhost/gitea/compose.yml <<'EOF'
# Gitea · the compose file for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker ............. https://docs.gitea.com/installation/install-with-docker
#   config cheat sheet . https://docs.gitea.com/administration/config-cheat-sheet
#
# One service on the computer you are sitting at. Paths are relative to
# ~/selfhost/gitea/. ROOT_URL is http://localhost:8208/ on this path. SSH is
# not published. Digest read from Docker Hub on 2026-08-07 for tag 1.27.1.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  gitea:
    image: gitea/gitea:1.27.1@sha256:34e3f6b75f5cbb6aebce588037fc5a53c84213e4d4b00da0a8d73e031a558e52
    container_name: gitea
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__database__DB_TYPE: sqlite3
      GITEA__server__DOMAIN: localhost
      GITEA__server__ROOT_URL: http://localhost:8208/
      GITEA__server__DISABLE_SSH: "true"
      GITEA__server__START_SSH_SERVER: "false"
      # Flip to "true" after the first account claims the instance (step 7).
      GITEA__service__DISABLE_REGISTRATION: "false"
    volumes:
      - ./data:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8208.
      - "127.0.0.1:8208:3000"
EOF
cd ~/selfhost/gitea && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, ROOT_URL set to http://localhost:8208/, no SSH.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. 8208 is bound to 127.0.0.1 only. Confirm:

```bash
grep -c '"127.0.0.1:' ~/selfhost/gitea/compose.yml
```

Assert: that count is exactly `1`.

## 7. Start and verify

```bash
cd ~/selfhost/gitea
docker compose pull
docker compose up -d
for i in $(seq 1 36); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8208/); echo "$i $code"; case "$code" in 200|301|302|303|307|308) break ;; esac; sleep 5; done
curl -sSL http://localhost:8208/ | grep -ciE 'gitea|install|register|sign'
docker compose ps
```

Assert: loop ends with 2xx/3xx and the body matches Gitea or the install surface. If the port is
already allocated, find what holds 8208 and stop until it is free.

STOP: tell the user to open http://localhost:8208/, finish the install wizard if shown, create
the first admin account, and confirm they are signed in. Do not continue until they confirm.

Close registration:

```bash
cd ~/selfhost/gitea
sed -i.bak 's/GITEA__service__DISABLE_REGISTRATION: "false"/GITEA__service__DISABLE_REGISTRATION: "true"/' compose.yml
docker compose up -d
sleep 8
echo -n 'disable_flag_count='; grep -c 'DISABLE_REGISTRATION: "true"' ~/selfhost/gitea/compose.yml
curl -sSL http://localhost:8208/user/sign_up -o /tmp/gitea-signup-followed.html
echo -n 'open_form_markers='; grep -ciE 'name="user_name"|id="user_name"' /tmp/gitea-signup-followed.html
```

Assert: `disable_flag_count` is `1` and `open_form_markers` is `0`. If the form is still open,
stop.

Handoff: create a repo in the UI, generate a personal access token, then:

```bash
git clone http://localhost:8208/<username>/<repo>.git
```

Push with username + token. Prefer HTTPS over any SSH remote the UI suggests.

STOP: do not continue until they confirm a push works or they explicitly defer the push. Do not continue until they confirm.

## 8. First backup and restore

```bash
cd ~/selfhost/gitea
docker compose stop
tar -C ~/selfhost/gitea -czf ~/selfhost/gitea/backups/gitea-$(date +%F).tar.gz data compose.yml
docker compose start
ls -lh ~/selfhost/gitea/backups/
```

Assert: archive exists and is non-empty. Print its size. Copy it off this computer to a sync
folder or USB stick. To restore: `docker compose down`, move aside `data`, untar, `docker compose up -d`.
`data/` is every repository and the account database.

## 9. Updating later

Releases: https://github.com/go-gitea/gitea/releases. Backup first, edit the image line in
~/selfhost/gitea/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/gitea
docker compose pull
docker compose up -d
docker compose logs --tail 40 gitea
```

Confirm sign-in still works and `DISABLE_REGISTRATION` is still `"true"`.

## 10. What will probably go wrong

You will try an SSH remote against localhost:22 and get connection refused. This install never
started an SSH listener for git. Use HTTPS and a token. Second: leaving registration open after
the first account, so the next person on the same machine can create another admin. Flip the
flag. Third: filling the disk under `data/` with large binaries without LFS planning; watch free
space before big pushes.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS on this path.
- Do not rebind 8208 to 0.0.0.0.
- Do not publish git over SSH.
- Do not skip closing registration after the first account.
compose.local.ymlthe services, pinned · local layout31 lines

authored from upstream docs, never pasted · 1,334 bytes

# Gitea · the compose file for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker ............. https://docs.gitea.com/installation/install-with-docker
#   config cheat sheet . https://docs.gitea.com/administration/config-cheat-sheet
#
# One service on the computer you are sitting at. Paths are relative to
# ~/selfhost/gitea/. ROOT_URL is http://localhost:8208/ on this path. SSH is
# not published. Digest read from Docker Hub on 2026-08-07 for tag 1.27.1.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  gitea:
    image: gitea/gitea:1.27.1@sha256:34e3f6b75f5cbb6aebce588037fc5a53c84213e4d4b00da0a8d73e031a558e52
    container_name: gitea
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__database__DB_TYPE: sqlite3
      GITEA__server__DOMAIN: localhost
      GITEA__server__ROOT_URL: http://localhost:8208/
      GITEA__server__DISABLE_SSH: "true"
      GITEA__server__START_SSH_SERVER: "false"
      # Flip to "true" after the first account claims the instance (step 7).
      GITEA__service__DISABLE_REGISTRATION: "false"
    volumes:
      - ./data:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8208.
      - "127.0.0.1:8208:3000"

agent-readable mirror: /self-host/gitlab.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned38 lines

authored from upstream docs, never pasted · 1,768 bytes

# Gitea · the compose file for this service. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker ............. https://docs.gitea.com/installation/install-with-docker
#   config cheat sheet . https://docs.gitea.com/administration/config-cheat-sheet
#   reverse proxy ...... https://docs.gitea.com/administration/reverse-proxies
#
# One service. SQLite lives under /data (no separate database container).
# ROOT_URL and DOMAIN must match the public hostname or clone links and
# redirects will be wrong. SSH is not published on this host: git over HTTPS
# with a personal access token is the path this install documents. After the
# first account is created, set GITEA__service__DISABLE_REGISTRATION=true and
# restart so the open signup door closes. Digest read from Docker Hub on
# 2026-08-07 for tag 1.27.1.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  gitea:
    image: gitea/gitea:1.27.1@sha256:34e3f6b75f5cbb6aebce588037fc5a53c84213e4d4b00da0a8d73e031a558e52
    container_name: gitea
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__database__DB_TYPE: sqlite3
      GITEA__server__DOMAIN: <DOMAIN>
      GITEA__server__ROOT_URL: https://<DOMAIN>/
      GITEA__server__DISABLE_SSH: "true"
      GITEA__server__START_SSH_SERVER: "false"
      # Flip to "true" after the first account claims the instance (step 7).
      GITEA__service__DISABLE_REGISTRATION: "false"
    volumes:
      # Repos, SQLite, attachments, avatars, app.ini.
      - /srv/gitea/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8208.
      # No SSH port is published.
      - "127.0.0.1:8208:3000"
Caddyfilethe hostname and TLS26 lines

authored from upstream docs, never pasted · 896 bytes

# Gitea · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.gitea.com/administration/reverse-proxies and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8208 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Git over HTTPS uses
	# this same reverse_proxy; no SSH port is published by this install.
	reverse_proxy 127.0.0.1:8208
}
install.shthe same install, no agent126 lines

authored from upstream docs, never pasted · 5,455 bytes

#!/usr/bin/env bash
# Gitea · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=git.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.gitea.com/installation/install-with-docker
#   https://docs.gitea.com/administration/config-cheat-sheet
#   https://docs.gitea.com/administration/reverse-proxies
#
# No secret is generated here. The first browser visitor to complete the install
# wizard creates the admin account (first claimant). This script starts with
# registration open, prints how to claim the instance, and leaves DISABLE_REGISTRATION
# false until you flip it after that account exists (see the summary).
#
# Git over HTTPS with a personal access token is the documented path. SSH is not
# published on this host.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/gitea}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. git.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; this install wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# data/ is the whole state: git repos, SQLite, attachments, avatars, app.ini.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
sed -i "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/compose.yml"

cd "$APP_DIR"
docker compose config >/dev/null

# --- 3. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-gitea"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 4. Ports: two open, and 8208 is not one of them -------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8208 and 22 stay as they were"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 5. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/"
for _ in $(seq 1 36); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	case "$code" in 200|301|302|303|307|308) break ;; esac
	sleep 5
done
[ -n "${code:-}" ] || die "no HTTP response from https://${DOMAIN_HOST}/"

curl -sSL "https://${DOMAIN_HOST}/" | grep -qi 'gitea\|install\|register\|sign' \
	|| die "the page at https://${DOMAIN_HOST}/ does not look like Gitea. Check: docker compose logs --tail 40 gitea"

# --- 6. First backup of the empty-but-initialized data dir -------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/gitea-${STAMP}.tar.gz" \
	-C "$APP_DIR" data compose.yml \
	-C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/gitea-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	Gitea is answering at https://${DOMAIN_HOST}

	  1. Open https://${DOMAIN_HOST}/ now and finish the install wizard. The first
	     account you create is the admin (first claimant). Do this before anyone
	     else can reach the hostname.
	  2. After that account exists, close registration:
	       cd $APP_DIR
	       sed -i 's/GITEA__service__DISABLE_REGISTRATION: "false"/GITEA__service__DISABLE_REGISTRATION: "true"/' compose.yml
	       docker compose up -d
	     Then confirm signup is gone: open https://${DOMAIN_HOST}/user/sign_up in a
	     private window and check that no registration form is offered. Also:
	       grep -c 'DISABLE_REGISTRATION: "true"' $APP_DIR/compose.yml
	     must print 1.
	  3. Git over HTTPS: create a personal access token under Settings, then:
	       git clone https://${DOMAIN_HOST}/<you>/<repo>.git
	       git push  (username + token as password)
	     SSH is not published by this install.
	  4. First backup written to $APP_DIR/backups. Copy it off this disk tonight.
	  5. NOT YET VERIFIED on a clean harness machine.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel GitLab.

  • SQLite is fine for a personal forge and wrong for a busy company. Plan Postgres when collaborators and CI multiply.
  • You own backups of the data volume: repositories, attachments, LFS objects and the database file move together under /data.
  • This install pushes and pulls over HTTPS with a personal access token. SSH is not published unless you deliberately open it later.
  • Actions, packages and SSO exist but are not GitHub's or GitLab's full ecosystem. Integrations may be missing.
  • No vendor SLA. When the disk fills, pushes fail for everyone you invited.

Where this came from

“Gitea is a painless, self-hosted, all-in-one software development service.”

  • Upstream documents Docker installation with a data volume and HTTP on port 3000; SQLite is supported for small instances. source
  • ROOT_URL must match the public URL or generated clone links and redirects will be wrong; DOMAIN and ROOT_URL are set via GITEA__server__* environment variables. source
  • DISABLE_REGISTRATION closes open signup so only an admin can create further users after the first account. source
  • Caddy obtains and renews TLS certificates automatically for any public hostname named in the Caddyfile. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host GitLab?

    Not GitLab itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Gitea. A lightweight self-hosted Git service with issues, PRs and package registries you run yourself. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 10 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces GitLab?

    Gitea. A lightweight self-hosted Git service with issues, PRs and package registries you run yourself. A lightweight self-hosted forge for repos, issues and PRs. One container with SQLite for personal scale; not a feature clone of GitLab Premium CI and security scanners. Gitea is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to GitLab?

    1024 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Gitea itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: GitLab Premium, $29/mo — $348 a year, 1 seat assumed.

  • How hard is it really?

    ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the Gitea install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Gitea on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Gitea on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Git remotes that point at localhost only work on this computer, so a laptop and a desktop will not share the same origin without a real hostname later. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.