# Step-by-step: offline Gitea server with Gitleaks

This guide installs the supplied bundle on a **fresh Linux x86_64/amd64 host**
without Internet access. It uses rootful Podman, matching the tested installation.
Run the commands on the offline server unless a step says otherwise.

| Component | Bundled configuration |
| --- | --- |
| Gitea image | `docker.io/gitea/gitea:1.22`, bundled as Gitea **1.22.6** |
| Gitleaks | **8.30.0**, binary and built-in detection rules |
| Database | SQLite; no separate database container required |
| Web address | `http://localhost:3000` |
| Git SSH port | `localhost:2222` |
| Container name | `devsecops-gitea` |
| Integration | Server-side Git pre-receive hook |

Gitleaks runs inside the Gitea container from read-only mounted files. It rejects
Git pushes containing detected secrets. No separate Gitleaks service, Actions
runner, license activation or online scanner database is needed.

## 1. Prepare the offline server prerequisites

Have these tools installed from your approved offline operating-system media or
local package repository:

- Podman and its container-runtime/networking dependencies.
- Bash, Python 3, curl, tar, gzip and coreutils, including `sha256sum`.
- Git, for the manual and automated push tests.
- `ip` and `unshare`, only if you want the optional network-isolation test.

The archive contains application assets, **not host RPMs or an operating system**.
For RHEL 9.6, obtain the matching RPMs and all dependencies on an entitled RHEL 9.6
staging host before disconnecting. Host package installation depends on your
configured offline repositories; the commands below assume it is complete.

Check the host:

```bash
uname -m
command -v podman bash python3 curl tar gzip sha256sum git
sudo podman info
```

Expected architecture: `x86_64`. Allow space for the extracted image archive,
the runtime's loaded image and your future repositories. The compressed bundle
is approximately 83 MB; repository growth determines ongoing storage needs.

If `ss` is available, check the requested ports:

```bash
sudo ss -ltn '( sport = :3000 or sport = :2222 )'
```

Neither port should already have a listener. Keep SELinux enabled: the supplied
start script uses `:Z` for the installation's private bind mounts.

## 2. Collect the transfer files on the connected machine

The prepared workspace already has these files in `/work/acb/`:

```text
devsecops-gitea-offline-linux-amd64.tar.gz
devsecops-gitea-offline-linux-amd64.tar.gz.sha256
```

Copy both files to approved transfer media. The setup guide is included in the
updated archive. You do not need to download the image or Gitleaks again.

If rebuilding the distribution on a connected staging machine is necessary:

```bash
cd /work/acb/devsecops-gitea
bash scripts/download.sh
bash scripts/package.sh
```

The downloader needs `curl`, `skopeo`, `tar` and `sha256sum`. It retrieves the
pinned Gitea amd64 image and Gitleaks release, and checks the Gitleaks release
against the publisher's checksum list. The packager writes the archive and
sidecar into the parent directory.

The distribution excludes `data/`, `credentials.json` and `backups/`. A fresh
offline installation generates its own administrator password. To migrate the
existing instance instead, use the backup/restore procedure in step 12.

## 3. Transfer, verify and extract on the offline server

Copy both files into a **new installation parent directory** owned by your user,
for example `$HOME/offline-gitea`. Keep that directory at its permanent location
after starting the container, because the container uses absolute bind mounts.

```bash
mkdir -p "$HOME/offline-gitea"
cd "$HOME/offline-gitea"
```

Place the archive and sidecar here using your transfer media, then run:

```bash
sha256sum -c devsecops-gitea-offline-linux-amd64.tar.gz.sha256
tar -xzf devsecops-gitea-offline-linux-amd64.tar.gz
cd devsecops-gitea
sha256sum --check --quiet SHA256SUMS
```

The first check should print `OK`. The second is silent on success. If either
fails, stop and obtain an intact copy before installing. Obtain the checksum
sidecar through a trusted channel when authenticity verification is required.
Do not extract over an existing Gitea installation.

**For the remaining setup commands, stay in this extracted `devsecops-gitea`
directory.** Do not use the upstream `bin/README.md` as the installation guide;
it describes the standalone Gitleaks project.

## 4. Load the Gitea image without Internet access

```bash
sudo bash scripts/load.sh
sudo podman images docker.io/gitea/gitea
```

The load script verifies the distribution manifest and imports
`images/gitea-1.22-linux-amd64.tar` into the rootful Podman image store.
The resulting image should have the `1.22` tag. No registry pull is needed.

Use `sudo` consistently for Podman commands in this guide. Rootful and rootless
Podman normally use separate image stores.

## 5. Start the Gitea server with Gitleaks mounted

```bash
sudo bash scripts/start.sh
```

Expected final message:

```text
Gitea ready at http://localhost:3000
```

On first use, the script verifies the expected image ID, creates `data/`, and
starts the container with `--pull=never`. It configures:

- SQLite and persistent application data under `data/`.
- Web and SSH ports bound only to `127.0.0.1`.
- Gitleaks binary, server policy, hook and Git templates as read-only mounts.
- Automatic hook inheritance for new repositories initialized by this instance.
- Disabled public registration and external avatar fetching.
- Locked installation, so no browser installation wizard is required.

Gitea's offline settings are application settings, not an egress firewall. The
offline server's network environment determines whether external access is possible.

Check health and versions:

```bash
curl -fsS http://127.0.0.1:3000/api/healthz
sudo podman ps --filter name=devsecops-gitea
sudo podman exec --user git devsecops-gitea gitea --version
sudo podman exec --user git devsecops-gitea /opt/gitleaks/bin/gitleaks version
```

Expected: health status `pass`, a running container, Gitea `1.22.6`, and Gitleaks
`8.30.0`. This installation intentionally retains the requested Gitea 1.22 version.

## 6. Create the administrator and sign in

For a **fresh installation**, run:

```bash
sudo python3 scripts/init-admin.py
sudo cat credentials.json
```

The script creates `gitea-admin` with a random password and stores the credentials
in a local mode-0600 file. Read the password there; no password is embedded in
this guide. Open **http://localhost:3000** on the server and sign in.

If accessing a remote server, run this on your workstation instead, substituting
your SSH login and server address:

```bash
ssh -L 3000:127.0.0.1:3000 user@offline-server
```

Keep the SSH session open and visit `http://localhost:3000` on your workstation.
This requires a reachable internal SSH connection and a free local port 3000.

Public registration is disabled; use the administrator interface to create any
additional users. If you later change the admin password in Gitea, the saved
credentials file is not updated automatically. The automated test reads that
file, so keep its password synchronized if you continue using that test.

## 7. Create a repository and confirm the Gitleaks hook

In Gitea:

1. Choose **New Repository**.
2. Use owner `gitea-admin` and repository name `gitleaks-demo`.
3. Make it private if desired.
4. Leave repository initialization unchecked so it starts empty.
5. Create the repository.

The configured Git template adds Gitleaks automatically for new repositories.
Confirm the hook exists:

```bash
sudo podman exec --user git devsecops-gitea \
  ls -l /data/git/repositories/gitea-admin/gitleaks-demo.git/hooks/pre-receive.d/20-gitleaks
```

For an existing, imported or restored repository, install or refresh the hook:

```bash
sudo bash scripts/enable-gitleaks.sh gitea-admin/gitleaks-demo
```

Substitute the actual lowercase `owner/repository` storage names and omit `.git`.
The installer preserves Gitea's hook dispatcher. Gitea's user-editable Git hooks
are disabled; that does not disable this filesystem-managed integration.

## 8. Test a clean push and a rejected secret manually

Run these Git commands as your normal user on the server. Use a fresh temporary
working directory so existing projects are not affected:

```bash
DEMO_DIR=$(mktemp -d "$HOME/gitleaks-demo.XXXXXXXX")
cd "$DEMO_DIR"
git init -b main
git config user.name "Gitleaks Tester"
git config user.email "tester@example.com"
printf '# Gitleaks demo\n' > README.md
git add README.md
git commit -m "Clean initial commit"
git remote add origin http://localhost:3000/gitea-admin/gitleaks-demo.git
git push -u origin main
```

Enter your Gitea username and password when Git prompts. The clean push should
succeed, and the remote output should report no leaks.

Create a **synthetic token that is not a real credential**:

```bash
printf 'github_token = "ghp_%s"\n' \
  '7zX9aB2cD4eF6gH8jK0mN3pQ5rS1tU9vW2yZ' > fake-secret.txt
git add fake-secret.txt
git commit -m "Test secret detection"
git push
```

This push must fail. Expected output includes:

```text
Push rejected: Gitleaks found secrets or could not complete the scan.
! [remote rejected] main -> main (pre-receive hook declined)
```

Remove the fixture **from the rejected commit** and retry:

```bash
git rm fake-secret.txt
git commit --amend -m "Remove synthetic secret"
git push
```

The corrected push should succeed. Deleting the file in a separate follow-up
commit would leave the secret in earlier history and still be rejected. The
amend procedure above applies to this new, rejected demonstration commit.

## 9. Run the supplied acceptance tests

Return to the installation directory:

```bash
cd "$HOME/offline-gitea/devsecops-gitea"
sudo python3 scripts/test-gitea.py
```

Use your actual installation path if you chose a different location. Each run
creates a new private `gitleaks-acceptance-*` repository and prints `PASS:` results
for clean pushes, secret rejection, history checks, annotated tags, multi-ref
push rejection, branch deletion and redaction. Results are written to
`evidence/acceptance.log`.

For an additional Podman-only network-isolated test:

```bash
sudo unshare --net bash scripts/test-offline.sh
```

This requires host privileges for network namespaces. It imports the archived
image into an empty temporary Podman store with no Internet route, then tests
the hook using local Git pushes inside a container with `--network=none`. It
does not stop the running Gitea server. Expected final result:

```text
PASS: archive loaded into an empty store and hooks executed offline
```

Tests update evidence files; keep the original archive for later integrity
verification. A changed evidence file can cause a subsequent full manifest check
to fail even when the original distribution was intact.

## 10. Understand the scan coverage

For each proposed non-deleted ref, the hook scans its reachable commit history
before Git accepts the push. It rejects detected secrets and scanner errors,
including a scan that exceeds the configured 120-second timeout. It also rejects
tags pointing to non-commit objects. Output redacts detected secrets.

The policy in `config/gitleaks.toml` is controlled by the server administrator.
Repository-provided configuration, ignore files and inline `gitleaks:allow`
comments cannot disable this configured gate.

This is a **Git push gate**. Imports, mirroring, direct filesystem changes and
some web/API writes can bypass receive hooks. Check imported repositories and
their existing contents explicitly. LFS payloads and binary artifacts are not
covered by this history scan. Results appear in Git push output, not an Actions
dashboard. Detection does not guarantee every possible secret will be found.

## 11. Stop, start and inspect the service

From the installation directory:

```bash
# Recent server logs
sudo podman logs --tail 100 devsecops-gitea

# Stop this server
sudo podman stop devsecops-gitea

# Start it again with the existing persistent data
sudo bash scripts/start.sh
```

The start script starts an existing container without recreating it. Changes to
the script's container creation options therefore do not reconfigure an existing
container. Keep `data/` and the installation directory in place.

A restart policy is configured, but automatic startup after a host reboot also
depends on the host's Podman/systemd setup. This bundle does not install a boot
service; run `scripts/start.sh` after reboot if necessary.

## 12. Back up or migrate an existing instance

To create a consistent backup from the running installation:

```bash
sudo bash scripts/backup.sh
```

The script briefly stops Gitea, saves `data/` and `credentials.json` into
`backups/gitea-data-TIMESTAMP.tar.gz`, and restarts the container. This private
archive contains repositories, SQLite data, application secrets and SSH keys.
Transfer it separately from the fresh-install distribution when migrating.

On a **fresh destination**:

1. Complete steps 1–4, but do not start the container or initialize an admin yet.
2. Copy the private backup onto the destination.
3. From the extracted `devsecops-gitea` directory, restore it with numeric ownership:

   ```bash
   sudo tar --numeric-owner -xzf /path/to/gitea-data-TIMESTAMP.tar.gz
   sudo bash scripts/start.sh
   ```

4. Sign in using the restored instance's existing credentials. Do not run
   `init-admin.py` for this restore.
5. Check repository hooks as in step 7 and verify health as in step 5.

Substitute the actual backup path. Never extract a backup over a running server
or an existing installation's data. Restore with the bundled image version
before planning any version changes.

## 13. Troubleshooting

| Symptom | Check or action |
| --- | --- |
| Image missing | Run `sudo bash scripts/load.sh`; use the same rootful runtime for load and start. |
| Unexpected image identity | Load the verified archive; the start script expects the bundled image ID. |
| Port already allocated | Inspect ports 3000 and 2222 and resolve the conflict before starting. |
| Web page unavailable | Check `/api/healthz`, `sudo podman ps`, and container logs. On another machine, use the SSH tunnel in step 6. |
| Permission or SELinux denial | Check container logs and host audit messages; retain the supplied `:Z` mounts and installation path. |
| Secret push succeeds | Confirm the repository's executable `20-gitleaks` hook exists, then run the installer in step 7 and repeat the synthetic test. |
| Clean tip still rejected | An earlier reachable commit may contain a secret; removing only the tip's file does not clean history. |
| Scanner times out | Inspect push output and history size; the administrator can review the timeout in `hooks/gitleaks-pre-receive`. |
| Automated test cannot authenticate | Confirm the current admin password matches the private `credentials.json`; changing it in the UI does not update the file. |
| Manifest fails after testing | Evidence may have changed. Verify a fresh extraction of the original archive. |

## Optional: use Docker instead of Podman

Docker must already be installed and its daemon running on the offline host.
Use the same archive and substitute these setup commands:

```bash
sudo env ENGINE=docker bash scripts/load.sh
sudo env ENGINE=docker bash scripts/start.sh
sudo env ENGINE=docker python3 scripts/init-admin.py
sudo env ENGINE=docker python3 scripts/test-gitea.py
```

Use `sudo docker` for direct runtime commands and `sudo env ENGINE=docker` for
the hook installer and backup script. The network-isolated `test-offline.sh`
specifically requires Podman. The recorded container validation used Podman.

For implementation details and upstream references, see [README.md](README.md).
