From 31fedb8d448209c80d1ec2f235e862581c3e1962 Mon Sep 17 00:00:00 2001 From: Fabio Scotto di Santolo Date: Thu, 1 Oct 2026 22:09:57 +0200 Subject: [PATCH] Prepare gated Gitea HTTPS and SSH cutover --- AGENTS.md | 12 +++- ansible/inventory/group_vars/server.yml | 33 +++++----- ansible/roles/profile_atlas/defaults/main.yml | 4 ++ ansible/roles/profile_atlas/tasks/gitea.yml | 45 +++++++++++++- .../templates/atlas-gitea.container.j2 | 14 ++++- .../tasks/backup_export_job.yml | 2 +- .../profile_server/tasks/gitea_ssh_proxy.yml | 61 +++++++++++++++++++ ansible/roles/profile_server/tasks/main.yml | 5 +- .../templates/prometheus-backup-export.sh.j2 | 2 +- .../prometheus-gitea-ssh-proxy.service.j2 | 12 ++++ .../prometheus-gitea-ssh-proxy.socket.j2 | 9 +++ .../templates/server/docker-compose.yml.j2 | 6 ++ docs/atlas-gitea-migration.md | 58 ++++++++++++------ 13 files changed, 221 insertions(+), 42 deletions(-) create mode 100644 ansible/roles/profile_server/tasks/gitea_ssh_proxy.yml create mode 100644 ansible/roles/profile_server/templates/prometheus-gitea-ssh-proxy.service.j2 create mode 100644 ansible/roles/profile_server/templates/prometheus-gitea-ssh-proxy.socket.j2 diff --git a/AGENTS.md b/AGENTS.md index 2c38b67..d7be8f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,9 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora `ansible-playbook ansible/site.yml --limit atlas --tags gitea_final_restore --check --diff -e atlas_gitea_final_restore=true` - Prometheus final Gitea export helper (dry-run installs only; outage action remains opt-in): `ansible-playbook ansible/site.yml --limit prometheus --tags gitea_final_export --check --diff` + - Gitea cutover network configuration before activation: + `ansible-playbook ansible/site.yml --limit prometheus --tags gitea_cutover,prometheus_backup --check --diff -e server_gitea_on_atlas=true` + and `ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff` - Atlas network/share hardening: `ansible-playbook ansible/site.yml --limit atlas --tags hardening,sharing --check --diff` - Atlas ZFS snapshot retention and scrub timers: @@ -289,7 +292,8 @@ successfully. The first monthly scrub remains a runtime check. and snapshot mounts were removed, the Borg service ended successfully, and the pool was healthy. - [ ] Include the new Gitea dataset in the next UUID-bound offline USB version and test a file restore from that version before accepting production writes; the UUID-bound disk is connected but its - LUKS mapper is closed, so the manual backup still requires interactive unlock. + LUKS mapper is closed, so the manual backup still requires interactive unlock. On 2026-10-01 the + operator could not unlock it and chose to defer traffic cutover until this check passes. - [x] Install a separate opt-in final Gitea export helper on Prometheus. Its 2026-10-01 targeted deployment and `bash -n` passed while Gitea and NPM stayed running. It refuses an active export timer, stops only Gitea, verifies SQLite, publishes a checksum-verified Gitea-only version for @@ -299,6 +303,12 @@ successfully. The first monthly scrub remains a runtime check. layout before replacing the marked rehearsal, and rolls back a failed swap. Synthetic success and rollback tests and a second idempotent rehearsal run passed on 2026-10-01; the final gate has **not** been invoked. +- [x] Prepare, but do not activate, the Atlas LAN rootless Quadlet and Prometheus TCP/2222 socket + proxy. NPM's two existing `gitea:3000` Proxy Hosts will resolve that name to Atlas through a + managed Compose `extra_hosts` entry after the source container is removed; no NPM database edit + is needed. The future-mode Prometheus check-run passed, both current-mode runs were idempotent, + the new systemd units passed verification, and source HTTP remained 200 on 2026-10-01. Public + 2222 is closed and the target remains inactive until the explicit cutover flags are enabled. - [ ] After an explicit outage approval, perform the final consistent copy and HTTPS/SSH cutover, then remove Gitea from Prometheus' desired stack and backup export without deleting source data. - [ ] Design and deploy Nextcloud as another explicitly temporary Atlas service before Uranus. Give it diff --git a/ansible/inventory/group_vars/server.yml b/ansible/inventory/group_vars/server.yml index 8092dae..75aa3d8 100644 --- a/ansible/inventory/group_vars/server.yml +++ b/ansible/inventory/group_vars/server.yml @@ -90,23 +90,22 @@ server_backup_export_start_timer: false # Explicit Gitea cutover helper: installed separately from any outage action. server_gitea_cutover_tools_enabled: false server_gitea_final_export: false +server_gitea_on_atlas: false +server_gitea_atlas_address: "{{ hostvars['atlas'].ansible_host }}" +server_gitea_ssh_public_port: 2222 +server_gitea_ssh_target_port: 2222 server_backup_export_source_keep: 3 -server_backup_export_paths: - - opt/npm/data - - opt/npm/letsencrypt - - opt/gitea/data - - home/git/.ssh - - opt/docker/server/docker-compose.yml - - etc/systemd/system/podman-compose-server.service - - etc/ssh/sshd_config - - etc/ssh/sshd_config.d - - etc/firewalld - - etc/wireguard/wg0.conf -server_backup_export_excludes: - - opt/npm/data/logs - - opt/gitea/data/gitea/log - - opt/gitea/data/gitea/tmp - - opt/gitea/data/gitea/sessions - - opt/gitea/data/gitea/indexers +server_backup_export_paths: >- + {{ ['opt/npm/data', 'opt/npm/letsencrypt'] + + ([] if server_gitea_on_atlas | bool else ['opt/gitea/data', 'home/git/.ssh']) + + ['opt/docker/server/docker-compose.yml', + 'etc/systemd/system/podman-compose-server.service', + 'etc/ssh/sshd_config', 'etc/ssh/sshd_config.d', + 'etc/firewalld', 'etc/wireguard/wg0.conf'] }} +server_backup_export_excludes: >- + {{ ['opt/npm/data/logs'] + + ([] if server_gitea_on_atlas | bool else + ['opt/gitea/data/gitea/log', 'opt/gitea/data/gitea/tmp', + 'opt/gitea/data/gitea/sessions', 'opt/gitea/data/gitea/indexers']) }} server_ssh_authorized_keys: [] server_ssh_authorized_key_directory: "{{ server_user_home }}/.ssh/authorized_keys.d" diff --git a/ansible/roles/profile_atlas/defaults/main.yml b/ansible/roles/profile_atlas/defaults/main.yml index cd1822c..5e7d354 100644 --- a/ansible/roles/profile_atlas/defaults/main.yml +++ b/ansible/roles/profile_atlas/defaults/main.yml @@ -178,6 +178,10 @@ atlas_gitea_dataset: "{{ atlas_zfs_pool }}/services/data/gitea" atlas_gitea_mountpoint: "{{ atlas_app_data_mountpoint }}/gitea" atlas_gitea_quadlet_dir: "{{ atlas_gitea_home }}/.config/containers/systemd" atlas_gitea_image: docker.gitea.com/gitea:1.25.2-rootless +atlas_gitea_production_enabled: false +atlas_gitea_bind_address: "{{ ansible_host }}" +atlas_gitea_http_port: 3000 +atlas_gitea_ssh_port: 2222 atlas_gitea_staging_bind_address: 127.0.0.1 atlas_gitea_staging_http_port: 3001 atlas_gitea_staging_ssh_port: 2223 diff --git a/ansible/roles/profile_atlas/tasks/gitea.yml b/ansible/roles/profile_atlas/tasks/gitea.yml index 5df4e88..5c3732b 100644 --- a/ansible/roles/profile_atlas/tasks/gitea.yml +++ b/ansible/roles/profile_atlas/tasks/gitea.yml @@ -14,10 +14,25 @@ - atlas_gitea_gid | int != atlas_admin_gid | int - atlas_gitea_gid | int != atlas_immich_gid | int - atlas_gitea_staging_bind_address == '127.0.0.1' + - not (atlas_gitea_production_enabled | bool) or atlas_manage_firewall | bool + - not (atlas_gitea_production_enabled | bool) or atlas_gitea_bind_address == ansible_host fail_msg: >- Rootless Gitea preparation requires Atlas storage, an isolated service identity and dataset, and loopback-only staging ports. + - name: Inspect the final-restore marker before production activation + ansible.builtin.stat: + path: "{{ atlas_gitea_mountpoint }}/.final-sha256" + register: atlas_gitea_final_marker + when: atlas_gitea_production_enabled | bool + + - name: Refuse production activation without the final consistent restore + ansible.builtin.assert: + that: + - atlas_gitea_final_marker.stat.isreg | default(false) + fail_msg: Restore the final stopped-source Gitea export before enabling production. + when: atlas_gitea_production_enabled | bool + - name: Create the dedicated Gitea group ansible.builtin.group: name: "{{ atlas_gitea_group }}" @@ -114,7 +129,7 @@ state: started when: not ansible_check_mode - - name: Render the disabled rootless Gitea Quadlet + - name: Render the rootless Gitea Quadlet ansible.builtin.template: src: atlas-gitea.container.j2 dest: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container" @@ -122,6 +137,20 @@ group: "{{ atlas_gitea_group }}" mode: "0644" + - name: Permit only Aegis to reach production Gitea HTTP and SSH + ansible.posix.firewalld: + rich_rule: >- + rule family="ipv4" source address="{{ atlas_aegis_ip }}" + port port="{{ item }}" protocol="tcp" accept + zone: "{{ atlas_firewalld_zone }}" + state: "{{ 'enabled' if atlas_gitea_production_enabled | bool else 'disabled' }}" + permanent: true + immediate: true + loop: + - "{{ atlas_gitea_http_port }}" + - "{{ atlas_gitea_ssh_port }}" + when: atlas_manage_firewall | bool + - name: Reload the rootless Gitea user manager without starting Gitea become_user: "{{ atlas_gitea_username }}" ansible.builtin.systemd: @@ -131,3 +160,17 @@ XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}" DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus" when: not ansible_check_mode + + - name: Start and enable the rootless Gitea user Quadlet after final restore + become_user: "{{ atlas_gitea_username }}" + ansible.builtin.systemd: + name: atlas-gitea.service + scope: user + state: started + enabled: true + environment: + XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus" + when: + - atlas_gitea_production_enabled | bool + - not ansible_check_mode diff --git a/ansible/roles/profile_atlas/templates/atlas-gitea.container.j2 b/ansible/roles/profile_atlas/templates/atlas-gitea.container.j2 index 582b542..092327c 100644 --- a/ansible/roles/profile_atlas/templates/atlas-gitea.container.j2 +++ b/ansible/roles/profile_atlas/templates/atlas-gitea.container.j2 @@ -1,14 +1,19 @@ -# Managed by Ansible. Staging only: no [Install], no automatic start. +# Managed by Ansible. Staging does not start automatically. [Unit] -Description=Atlas rootless Gitea staging target +Description=Atlas rootless Gitea RequiresMountsFor={{ atlas_gitea_mountpoint }} [Container] ContainerName=atlas-gitea Image={{ atlas_gitea_image }} UserNS=keep-id:uid=1000,gid=1000 +{% if atlas_gitea_production_enabled | bool %} +PublishPort={{ atlas_gitea_bind_address }}:{{ atlas_gitea_http_port }}:3000 +PublishPort={{ atlas_gitea_bind_address }}:{{ atlas_gitea_ssh_port }}:2222 +{% else %} PublishPort={{ atlas_gitea_staging_bind_address }}:{{ atlas_gitea_staging_http_port }}:3000 PublishPort={{ atlas_gitea_staging_bind_address }}:{{ atlas_gitea_staging_ssh_port }}:2222 +{% endif %} Volume={{ atlas_gitea_mountpoint }}/data:/var/lib/gitea:Z Volume={{ atlas_gitea_mountpoint }}/config:/etc/gitea:Z NoNewPrivileges=true @@ -18,3 +23,8 @@ DropCapability=all Restart=on-failure RestartSec=10 TimeoutStartSec=900 +{% if atlas_gitea_production_enabled | bool %} + +[Install] +WantedBy=default.target +{% endif %} diff --git a/ansible/roles/profile_server/tasks/backup_export_job.yml b/ansible/roles/profile_server/tasks/backup_export_job.yml index 48cff77..ebed1fa 100644 --- a/ansible/roles/profile_server/tasks/backup_export_job.yml +++ b/ansible/roles/profile_server/tasks/backup_export_job.yml @@ -44,7 +44,7 @@ when: server_backup_export_enabled | bool - name: Install Prometheus backup export helper - tags: [services, backup, prometheus_backup] + tags: [services, backup, prometheus_backup, gitea_cutover] ansible.builtin.template: src: prometheus-backup-export.sh.j2 dest: /usr/local/sbin/prometheus-backup-export diff --git a/ansible/roles/profile_server/tasks/gitea_ssh_proxy.yml b/ansible/roles/profile_server/tasks/gitea_ssh_proxy.yml new file mode 100644 index 0000000..ceb53eb --- /dev/null +++ b/ansible/roles/profile_server/tasks/gitea_ssh_proxy.yml @@ -0,0 +1,61 @@ +--- +- name: Validate the Prometheus Gitea SSH cutover inputs + tags: [services, gitea_cutover] + ansible.builtin.assert: + that: + - server_gitea_cutover_tools_enabled | bool + - server_gitea_atlas_address is match('^[0-9]{1,3}(\.[0-9]{1,3}){3}$') + - server_gitea_ssh_public_port | int > 1024 + - server_gitea_ssh_public_port | int < 65536 + - server_gitea_ssh_target_port | int > 1024 + - server_gitea_ssh_target_port | int < 65536 + - server_gitea_ssh_public_port | int != 22 + fail_msg: Keep administrative SSH on 22 and provide the Atlas rootless Gitea SSH endpoint. + when: server_gitea_on_atlas | bool + +- name: Install the Gitea SSH socket proxy units without activating them + tags: [services, gitea_cutover] + ansible.builtin.template: + src: "{{ item }}.j2" + dest: "/etc/systemd/system/{{ item }}" + owner: root + group: root + mode: "0644" + loop: + - prometheus-gitea-ssh-proxy.socket + - prometheus-gitea-ssh-proxy.service + loop_control: + label: "{{ item }}" + register: server_gitea_ssh_proxy_units + when: server_gitea_cutover_tools_enabled | bool + +- name: Reload systemd after Gitea SSH proxy unit changes + tags: [services, gitea_cutover] + ansible.builtin.systemd: + daemon_reload: true + when: + - server_gitea_cutover_tools_enabled | bool + - server_gitea_ssh_proxy_units is changed + - not ansible_check_mode + +- name: Manage the public Gitea SSH socket separately from administrative SSH + tags: [services, gitea_cutover] + ansible.builtin.systemd: + name: prometheus-gitea-ssh-proxy.socket + state: "{{ 'started' if server_gitea_on_atlas | bool else 'stopped' }}" + enabled: "{{ server_gitea_on_atlas | bool }}" + when: + - server_gitea_cutover_tools_enabled | bool + - not ansible_check_mode + +- name: Open only the public Gitea SSH port after cutover + tags: [services, gitea_cutover] + ansible.posix.firewalld: + port: "{{ server_gitea_ssh_public_port }}/tcp" + zone: "{{ server_firewalld_zone }}" + state: "{{ 'enabled' if server_gitea_on_atlas | bool else 'disabled' }}" + permanent: true + immediate: true + when: + - server_gitea_cutover_tools_enabled | bool + - server_firewall_backend == 'firewalld' diff --git a/ansible/roles/profile_server/tasks/main.yml b/ansible/roles/profile_server/tasks/main.yml index 5ceffef..9163fcf 100644 --- a/ansible/roles/profile_server/tasks/main.yml +++ b/ansible/roles/profile_server/tasks/main.yml @@ -37,7 +37,7 @@ label: "{{ item.dest }}" - name: Render server templates - tags: [dotfiles, dotfiles:server] + tags: [dotfiles, dotfiles:server, gitea_cutover] ansible.builtin.template: src: "{{ item.src }}" dest: "{{ item.dest if item.dest.startswith('/') else server_user_home ~ '/' ~ item.dest }}" @@ -62,6 +62,9 @@ - name: Import explicit Prometheus Gitea final-export tasks ansible.builtin.import_tasks: gitea_final_export.yml +- name: Import Prometheus Gitea SSH proxy tasks + ansible.builtin.import_tasks: gitea_ssh_proxy.yml + - name: Ensure server SSH authorized key fragments directory exists tags: [services, ssh] ansible.builtin.file: diff --git a/ansible/roles/profile_server/templates/prometheus-backup-export.sh.j2 b/ansible/roles/profile_server/templates/prometheus-backup-export.sh.j2 index 9be174b..6a726bf 100644 --- a/ansible/roles/profile_server/templates/prometheus-backup-export.sh.j2 +++ b/ansible/roles/profile_server/templates/prometheus-backup-export.sh.j2 @@ -59,7 +59,7 @@ stack_stopped=true systemctl stop "$stack_unit" tar --acls --xattrs --selinux "${excludes[@]}" -C / -cf "$stage/payload.tar" "${paths[@]}" systemctl start "$stack_unit" -for container in nginx-proxy-manager gitea; do +for container in nginx-proxy-manager{% if not server_gitea_on_atlas | bool %} gitea{% endif %}; do running=false for _ in {1..30}; do if [[ $(podman inspect --format '{{ '{{.State.Running}}' }}' "$container" 2>/dev/null) == true ]]; then diff --git a/ansible/roles/profile_server/templates/prometheus-gitea-ssh-proxy.service.j2 b/ansible/roles/profile_server/templates/prometheus-gitea-ssh-proxy.service.j2 new file mode 100644 index 0000000..481f20b --- /dev/null +++ b/ansible/roles/profile_server/templates/prometheus-gitea-ssh-proxy.service.j2 @@ -0,0 +1,12 @@ +[Unit] +Description=Forward public Gitea SSH to Atlas through Aegis +Requires=prometheus-gitea-ssh-proxy.socket +After=network-online.target wg-quick@wg0.service + +[Service] +ExecStart=/usr/lib/systemd/systemd-socket-proxyd {{ server_gitea_atlas_address }}:{{ server_gitea_ssh_target_port }} +DynamicUser=true +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true diff --git a/ansible/roles/profile_server/templates/prometheus-gitea-ssh-proxy.socket.j2 b/ansible/roles/profile_server/templates/prometheus-gitea-ssh-proxy.socket.j2 new file mode 100644 index 0000000..ce625e5 --- /dev/null +++ b/ansible/roles/profile_server/templates/prometheus-gitea-ssh-proxy.socket.j2 @@ -0,0 +1,9 @@ +[Unit] +Description=Public Gitea SSH socket on Prometheus + +[Socket] +ListenStream=0.0.0.0:{{ server_gitea_ssh_public_port }} +NoDelay=true + +[Install] +WantedBy=sockets.target diff --git a/ansible/templates/server/docker-compose.yml.j2 b/ansible/templates/server/docker-compose.yml.j2 index 86b9afb..69003ba 100644 --- a/ansible/templates/server/docker-compose.yml.j2 +++ b/ansible/templates/server/docker-compose.yml.j2 @@ -13,6 +13,10 @@ services: - "127.0.0.1:81:81" extra_hosts: - "host.containers.internal:host-gateway" +{% if server_gitea_on_atlas | bool %} + # Keep both existing NPM Proxy Hosts unchanged; their gitea name now resolves to Atlas. + - "gitea:{{ server_gitea_atlas_address }}" +{% endif %} volumes: - "/opt/npm/data:/data{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}" - "/opt/npm/letsencrypt:/etc/letsencrypt{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}" @@ -38,6 +42,7 @@ services: # networks: # - web +{% if not server_gitea_on_atlas | bool %} gitea: image: docker.gitea.com/gitea:1.25.2 container_name: gitea @@ -55,6 +60,7 @@ services: ports: - "3000:3000" - "127.0.0.1:222:22" +{% endif %} networks: diff --git a/docs/atlas-gitea-migration.md b/docs/atlas-gitea-migration.md index 222c929..6eb5e58 100644 --- a/docs/atlas-gitea-migration.md +++ b/docs/atlas-gitea-migration.md @@ -22,7 +22,7 @@ Uranus; NPM remains on Prometheus. and pull succeeded. The intended target is a separate `/zpool/services/data/gitea` dataset, not `Archive` or the backup dataset. - The approved cutover keeps NPM on Prometheus, changes the two HTTP Proxy - Hosts to Atlas over the Prometheus--Aegis gateway, and offers public Gitea + Hosts' effective upstream to Atlas over the Prometheus--Aegis gateway, and offers public Gitea SSH on port 2222 via the same gateway. Prometheus port 22 is unchanged. HTTPS and SSH must be validated together before declaring cutover. - Run Gitea as a **rootless user Quadlet** under a dedicated, non-login Atlas @@ -118,36 +118,58 @@ only metadata marked `gitea-cutover`, validates a private staged replacement, and swaps it for the marked rehearsal. The swap and its rollback path passed synthetic tests on 2026-10-01; the gate has not been used on live Gitea data. +The network change is also prepared but inactive. `server_gitea_on_atlas=true` +removes the rootful Gitea service from the desired Prometheus Compose stack, +adds `gitea:192.168.178.55` to NPM's container hosts file, and removes Gitea +from future Prometheus backup exports. Both existing NPM Proxy Host records +remain at `gitea:3000`, but that name then resolves to Atlas; no direct SQLite +edit or NPM login is required. A separate systemd socket on public TCP/2222 +proxies SSH to Atlas TCP/2222 over the gateway, leaving administrative TCP/22 +unchanged. Atlas' production flag changes the user Quadlet from loopback +staging ports to LAN ports 3000/2222, grants only Aegis access in firewalld, +and starts it **only** after the `.final-sha256` marker exists. Neither flag +is enabled yet. The future Prometheus configuration passed a check-run; the +installed socket units passed `systemd-analyze verify` while remaining +inactive. Source Gitea still answered HTTP 200 after preparation. + +The operator cannot unlock the UUID-bound USB disk now and chose to defer +traffic activation until a new USB version covers Gitea and its file restore +passes. This blocks the final export, production flags, and public cutover; +the prepared configuration alone does not constitute a migration. + 1. Agree on an outage and record source/target versions, pool health, the latest backups, SSH host-key fingerprints, and both current NPM routes. Stop the Prometheus export timer for the change window so it cannot restart the old Compose stack unexpectedly. -2. Quiesce source writes. Run one final consistent Prometheus export, pull it - to Atlas, verify checksum and timestamp, then stop the source Gitea. Keep +2. Quiesce source writes with the final-export helper: it stops Gitea before + the consistent export and leaves it stopped after success. Pull that export + to Atlas and verify checksum and timestamp. Keep `/opt/gitea/data` and `/home/git/.ssh` intact for rollback. Do not allow source Gitea to restart after accepting writes on Atlas. 3. Restore the final Gitea-only payload to the target and repeat integrity - checks. Set Gitea's advertised SSH port to 2222 while retaining its - existing HTTPS `ROOT_URL` and verified host keys. Start the pinned rootless - Atlas user Quadlet, - initially without public ingress; validate local HTTP, SQLite, repositories, - LFS/attachments, and SSH host-key identity. -4. Permit only Aegis' source-NAT address to reach Atlas' Gitea HTTP and SSH - ports. Enable the public TCP/2222 forward on Prometheus to Atlas over Aegis - without changing administrative TCP/22. Update **both** NPM Proxy Hosts - from `gitea:3000` to Atlas' HTTP endpoint. Do not change public DNS. + checks. Verify its advertised SSH port is 2222, its existing HTTPS + `ROOT_URL`, repositories, LFS/attachments, and SSH host-key identity. Enable + the production Atlas Quadlet only after the final-restore marker exists; + its firewall permits only Aegis to reach HTTP and SSH. Validate local HTTP + and the target service before switching NPM. +4. Enable the public TCP/2222 socket proxy on Prometheus to Atlas over Aegis + without changing administrative TCP/22. Switch Prometheus to the desired + NPM-only Compose stack and recreate NPM with the managed `gitea` host alias + so **both** existing Proxy Hosts reach Atlas without changing their database + records. The old Gitea data stays intact. Do not change public DNS. 5. Test HTTPS login, representative clone/push, LFS, and public SSH clone/push on port 2222 from outside the Atlas LAN. Record the last source write and first healthy target service times; do not claim RPO/RTO without measuring. -6. Only after successful traffic validation, remove Gitea from Prometheus' - desired Compose stack and its backup-export path/container checks, leaving - NPM and its backups operational. Do not delete the old data. Verify the - next Atlas snapshot/Borg run covers Gitea and test a restored target copy. +6. After successful traffic validation, resume the Prometheus NPM-only backup + export timer and verify its next result. Verify the next Atlas snapshot/Borg + run covers Gitea and test a restored target copy. Do not delete old source + data. ## Rollback gate -Before Atlas accepts writes, revert the two NPM routes, disable the public -2222 forward, and restart the unchanged source Gitea if target validation +Before Atlas accepts writes, restore the old Compose definition (removing the +NPM `gitea` host alias), disable the public 2222 proxy, and restart the +unchanged source Gitea if target validation fails. **After Atlas accepts writes, do not blindly restart the source:** its SQLite database and repositories are stale. Quiesce Atlas, capture its new data, and decide a reverse migration or an extended outage explicitly.