Когда исходники, сборки и артефакты живут у вас, исчезают странные ограничения по сетям и ценам «за трафик наружу», а безопасность перестаёт быть переговорами с чужой политикой. В «independent cloud» это ощущается особенно ярко: лёгкие VPS для Git и CI в нужных регионах, честные vCPU и NVMe без оверселла, предсказуемая сеть. Ниже — практичный путь: поднять Gitea или GitLab, подключить раннеры под разные сценарии, завести приватный реестр (минималистичный Docker Registry или тяжеловесный, но удобный Harbor), связать всё в устойчивый контур и не переплатить ни за что лишнее. Покажу конкретные конфиги на Ubuntu/Debian, без «магии» и с реальными доменами и портами. Примерный периметр: исходники на git.example.com, тяжёлые проекты при этом сидят на gitlab.example.com, лёгкий контейнерный CI в ci.example.com, реестр образов в registry.example.com, а для продвинутых политик артефактов — harbor.example.com. Сервер с публичным адресом 203.0.113.10, DNS настроены на A/AAAA-записи, сертификаты выпустим локально.
Сначала приведём систему в порядок и дадим сервисам воздух. Обновляем пакеты, включаем UFW, открываем только нужные порты и фиксируем тайм в NTP, иначе вебхуки и кеши будут жить в параллельной вселенной. На одном узле для начала хватит 4 vCPU и 8–16 ГБ RAM; если проектов много, выносите GitLab на отдельную машину — он любит память и диски.
sudo apt update && sudo apt -y full-upgrade
sudo apt -y install curl gnupg2 ca-certificates lsb-release jq ufw ntp
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
Дальше выбираем Git-сервер под характер команды. Gitea — быстрый и экономичный вариант, идеален как «независимый Git с легкими issue и PR», тянет десятки команд на крошечных инстансах. GitLab — тяжелее, зато «всё-в-одном» с богатым UI и нативными раннерами. Нередко их комбинируют: Gitea как простой и быстрый «источник правды» и Drone/Woodpecker как сверхлёгкий CI, а GitLab держат только там, где его UX действительно нужен.
Для Gitea удобно стартовать в Docker Compose с PostgreSQL и обратным прокси на Nginx. Так проще обновлять и переносить. Ниже — полностью рабочий стек, где Gitea живёт на git.example.com, база — в отдельном контейнере, трафик шифруется Nginx’ом, а резервные копии лежат на примонтированном каталоге. Версии используются конкретные, без «latest», чтобы обновления были осознанными.
# /opt/gitea/docker-compose.yml
services:
postgres:
image: postgres:15.6
environment:
POSTGRES_DB: gitea
POSTGRES_USER: gitea
POSTGRES_PASSWORD: gitea_S3cretP@ss
volumes:
- /opt/gitea/pgdata:/var/lib/postgresql/data
restart: unless-stopped
gitea:
image: gitea/gitea:1.21.11-rootless
environment:
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: postgres:5432
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
GITEA__database__PASSWD: gitea_S3cretP@ss
GITEA__server__DOMAIN: git.example.com
GITEA__server__ROOT_URL: https://git.example.com/
GITEA__server__SSH_DOMAIN: git.example.com
GITEA__server__SSH_PORT: 2222
GITEA__security__INSTALL_LOCK: true
GITEA__service__DISABLE_REGISTRATION: true
volumes:
- /opt/gitea/data:/var/lib/gitea
- /etc/timezone:/etc/timezone:ro
depends_on:
- postgres
expose:
- "3000"
- "2222"
restart: unless-stopped
nginx:
image: nginx:1.25.5
volumes:
- /opt/gitea/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
ports:
- "80:80"
- "443:443"
- "2222:2222"
depends_on:
- gitea
restart: unless-stopped
Конфигурация Nginx завершает TLS и аккуратно проксирует UI и SSH-порт Gitea (rootless-сборка слушает 2222 — это нормально). QUIC включать не обязательно, но приятно для веб-интерфейса.
# /opt/gitea/nginx.conf
server {
listen 80;
server_name git.example.com;
return 301 https://git.example.com$request_uri;
}
server {
listen 443 ssl http2;
server_name git.example.com;
ssl_certificate /etc/letsencrypt/live/git.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://gitea:3000;
}
# SSH over TCP 2222 пробрасываем напрямую
location /ssh {
proxy_pass http://gitea:2222;
}
}
# Проксирование сырого TCP для SSH (stream)
stream {
upstream gitea_ssh {
server gitea:2222;
}
server {
listen 2222;
proxy_pass gitea_ssh;
}
}
После выпуска сертификата стандартным образом через certbot certonly --standalone -d git.example.com стек стартует одной командой: docker compose up -d. При первом заходе под админом сразу выключайте само-регистрацию и подключайте SSO/LDAP, если он есть. В эту же Gitea очень органично встаёт Drone или Woodpecker как контейнерный CI: аутентификация происходит через OAuth Gitea, а каждый билд — это изолированный контейнер со своим набором секретов.
Для минималистичного CI берём Drone (или его максимально совместный форк Woodpecker). Сервер и раннер поднимаются в паре; сервер слушает вебхуки Gitea, раннер забирает задания из очереди. Ниже — конфигурация из двух контейнеров, где значения уже проставлены, а домен ci.example.com обслуживается через Nginx этого же узла или отдельного.
# /opt/drone/docker-compose.yml
services:
drone-server:
image: drone/drone:2.22.0
environment:
DRONE_GITEA_SERVER: https://git.example.com
DRONE_GITEA_CLIENT_ID: 7f3a7c1e0a2b4c99
DRONE_GITEA_CLIENT_SECRET: 4f1b9a6c7d8e0f21f4d2e5a7b6c9a1d2
DRONE_RPC_SECRET: 1f8a6c2e4b7d9a0c3e6f1a2b5c7d9e0f
DRONE_SERVER_HOST: ci.example.com
DRONE_SERVER_PROTO: https
DRONE_USER_CREATE: username:dev,admin:true
volumes:
- /opt/drone/data:/data
ports:
- "8080:80"
restart: unless-stopped
drone-runner:
image: drone/drone-runner-docker:1.8.3
environment:
DRONE_RPC_PROTO: http
DRONE_RPC_HOST: drone-server
DRONE_RPC_SECRET: 1f8a6c2e4b7d9a0c3e6f1a2b5c7d9e0f
DRONE_RUNNER_CAPACITY: 2
DRONE_RUNNER_NAME: runner-01
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- drone-server
restart: unless-stopped
Пайплайн описывается в файле .drone.yml в корне репозитория. Ниже — реальная сборка контейнера с BuildKit и публикацией в приватный реестр с кешированием слоёв. Логин происходит через секреты, заданные в UI Drone.
kind: pipeline
type: docker
name: build_and_push
steps:
- name: build
image: docker:26.1.3
environment:
DOCKER_BUILDKIT: 1
commands:
- echo $REG_PASS | docker login registry.example.com -u $REG_USER --password-stdin
- docker buildx create --use --name builder0
- docker buildx build --push --tag registry.example.com/acme/app:${DRONE_COMMIT_SHA} --cache-to type=registry,ref=registry.example.com/acme/app:cache,mode=max --cache-from type=registry,ref=registry.example.com/acme/app:cache .
secrets: [ REG_USER, REG_PASS ]
Если команде нужен более привычный «оркестр» со сложными пайплайнами, подключаем Jenkins. Его удобно держать как контейнер с внешним volume под JENKINS_HOME, а агенты поднимать под каждую сборку как ephemeral-контейнеры на этом же хосте. В Jenkinsfile ниже используется Docker агент, BuildKit и публикация в реестр; на практике такие пайплайны безболезненно расходятся на десятки проектов.
// Jenkinsfile в репозитории
pipeline {
agent { docker { image 'docker:26.1.3' args '-v /var/run/docker.sock:/var/run/docker.sock' } }
environment {
DOCKER_BUILDKIT = '1'
REGISTRY = 'registry.example.com'
IMAGE = 'acme/app'
}
stages {
stage('Login') {
steps {
sh 'echo $REG_PASS | docker login $REGISTRY -u $REG_USER --password-stdin'
}
}
stage('Build & Push') {
steps {
sh '''
docker buildx create --use --name builder0 || true
docker buildx build \
--tag $REGISTRY/$IMAGE:${GIT_COMMIT} \
--cache-to type=registry,ref=$REGISTRY/$IMAGE:cache,mode=max \
--cache-from type=registry,ref=$REGISTRY/$IMAGE:cache \
--push .
'''
}
}
}
options { timestamps() }
}
GitLab хорош там, где «надо всё и сразу». Установим Omnibus CE на отдельную машину gitlab.example.com и дадим ему RAM и диск. Суть установки проста: репозиторий, пакет, внешняя ссылка, затем gitlab-ctl reconfigure. Если включаете встроенный контейнерный реестр GitLab, убедитесь, что диск для registry быстрый, иначе сборки начнут ждать I/O.
# GitLab CE на Ubuntu/Debian
curl -fsSL https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.deb.sh | sudo bash
sudo EXTERNAL_URL="https://gitlab.example.com" apt -y install gitlab-ce
sudo gitlab-ctl reconfigure
Раннер GitLab ставится отдельно и регистрируется командой с токеном проекта или группы. Docker-executor остаётся самым предсказуемым: он изолирует зависимости билдов и не «загрязняет» хост. Пример ниже действительно регистрирует раннер под Docker на этом же узле.
sudo apt -y install gitlab-runner
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.example.com" \
--registration-token "GR1348941aBcDe" \
--executor "docker" \
--docker-image "docker:26.1.3" \
--description "runner-docker-01" \
--tag-list "docker,ubuntu" \
--run-untagged="true" \
--locked="false"
sudo systemctl enable --now gitlab-runner
Файл .gitlab-ci.yml ниже собирает образ и пушит его в приватный реестр, пользуясь BuildKit и кешами слоёв. Переменные REG_USER/REG_PASS задаются в настройках проекта.
stages: [ build ]
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
build-and-push:
stage: build
image: docker:26.1.3
services: [ docker:26.1.3-dind ]
script:
- echo "$REG_PASS" | docker login registry.example.com -u "$REG_USER" --password-stdin
- docker buildx create --use --name builder0 || true
- docker buildx build --push --tag registry.example.com/acme/app:$CI_COMMIT_SHA --cache-to type=registry,ref=registry.example.com/acme/app:cache,mode=max --cache-from type=registry,ref=registry.example.com/acme/app:cache .
Теперь к приватным реестрам. Есть два устойчивых пути. Минимальный — официальный registry:2, который прекрасен в своей простоте и удобен как реплика/кеш. Промышленный — Harbor: с UI, проектами, политиками, ретеншном, Trivy-сканером, робот-аккаунтами и репликациями между площадками. В повседневной жизни это выглядит так: на небольших проектах достаточно registry:2 за Nginx’ом с TLS и базовой аутентификацией; как только появляются разные команды и правила хранения артефактов — включается Harbor.
Для «маленького, но честного» реестра ставим registry:2 и Nginx с htpasswd. Пользователь dev и пароль задаются один раз, политики pull/push регулируются уровнями доступа в CI.
sudo apt -y install apache2-utils docker.io docker-compose-plugin
sudo mkdir -p /opt/registry/{data,auth}
htpasswd -bBc /opt/registry/auth/htpasswd dev S3cretDevPass
Compose-файл сводит вместе реестр и обратный прокси; TLS лежит в /etc/letsencrypt.
# /opt/registry/docker-compose.yml
services:
registry:
image: registry:2.8.3
environment:
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
volumes:
- /opt/registry/data:/var/lib/registry
expose:
- "5000"
restart: unless-stopped
nginx:
image: nginx:1.25.5
volumes:
- /opt/registry/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /opt/registry/auth:/etc/nginx/auth:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
ports:
- "80:80"
- "443:443"
depends_on:
- registry
restart: unless-stopped
Конфигурация Nginx проверяет логин/пароль, шифрует трафик и проксирует запросы к registry.
# /opt/registry/nginx.conf
server {
listen 80;
server_name registry.example.com;
return 301 https://registry.example.com$request_uri;
}
server {
listen 443 ssl http2;
server_name registry.example.com;
ssl_certificate /etc/letsencrypt/live/registry.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/registry.example.com/privkey.pem;
location /v2/ {
auth_basic "Private Registry";
auth_basic_user_file /etc/nginx/auth/htpasswd;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://registry:5000;
client_max_body_size 2G;
}
}
С Harbor подход иной: это набор сервисов (Core, Registry, Notary, Trivy, Portal), который ставится скриптом и docker-compose под системным пользователем. Минимальный конфиг указывает домен, включённый HTTPS и пароли администратора. Harbor удобен тем, что в один клик даёт проекты, роли, робот-аккаунты и репликации между регионами, а ещё умеет ретеншн «держать последние N образов» и вычищать висячие манифесты без боли.
# /opt/harbor/harbor.yml (фрагмент минимальной конфигурации)
hostname: harbor.example.com
https:
port: 443
certificate: /etc/letsencrypt/live/harbor.example.com/fullchain.pem
private_key: /etc/letsencrypt/live/harbor.example.com/privkey.pem
harbor_admin_password: "Adm1nHarborP@ss"
database:
password: "HarborDB_P@ss"
data_volume: /data
trivy:
enabled: true
После генерации harbor.yml установка запускается скриптом install.sh из дистрибутива Harbor; служебные контейнеры поднимутся автоматически, а UI станет доступен по https://harbor.example.com. На практике Harbor лучше держать на отдельном диске с быстрым NVMe — garbage collector в таком случае выполняется быстро, а репликации не страдают от узкого места I/O.
Секреты и доступы должны быть предсказуемыми. В Gitea включайте «подписанные вебхуки» и проверяйте их на стороне CI, в Drone/Woodpecker храните секреты на уровне репозитория или организации и используйте переменные окружения в шагах, в Jenkins пользуйтесь Credentials Store и консервативными правами на Job’ы, в GitLab держите переменные в «Protected» и связывайте их с защищёнными ветками. Логины в реестр делайте через docker login с токенами, а не с паролями от личных аккаунтов. Если есть возможность, переходите на подписанные образы через cosign и включайте проверки в деплоях — это почти бесплатная страховка от подмены.
Теперь немного про производительность и деньги. Самое дешёвое улучшение — BuildKit с кешем слоёв в реестре: шаги сборки перестают заново скачивать полмира, а образы выкатываются в считанные минуты. Второе — перенос реестра ближе к раннерам: каждый мегабайт скачивается локально, а не через границы регионов. Третье — раздельные узлы: Git ближе к людям (низкая латентность UI), раннеры ближе к реестру (низкая латентность pull/push). На практике это даёт двузначное сокращение времени пайплайна без доплаты за «магические ускорители». Ещё одна важная деталь — метрики: держите «health-страницы» на каждом сервисе и собирайте базовые показания в Prometheus/Grafana; если это кажется избыточным, начинайте с логов Nginx и docker stats — уже они дают понятную картину, где именно «тесно».
Сценарий миграции «в два шага». Сначала поднимаете Gitea/реестр и прокладываете к ним тестовый репозиторий и pipeline, убедившись, что пуши и сборка образа действительно живут на ваших дисках. Затем включаете Jenkins или Drone для реальных проектов и подмешиваете GitLab там, где он нужен. Раннеры можно размножать горизонтально, оставляя одинаковые конфиги и разделяя нагрузку через теги или очереди. Когда инфраструктура начинает «дышать» под привычным грузом, добавляете второй реестр в другом регионе и настраиваете репликацию: образы автоматически копируются ближе к «второму» рынку, пользователи перестают ждать межрегиональные сети.
Если хочется начать без долгих подготовок, удобно арендовать пару VPS с NVMe в близких к аудитории локациях и развернуть всё по конфигам из этого текста. Мы помогаем с установкой и преднастройкой: Git-сервер, выбранный раннер, реестр, сертификаты и базовая безопасность на UFW, а также берём на себя бесплатную миграцию репозиториев и CI-пайплайнов. Проверить доступность нужных регионов можно заранее и выбрать географию под ваши команды; дальше остаётся только нажать «push» и наблюдать, как сборки начинают вести себя предсказуемо.