홈서버 IaC 전환기 (2) [with Infisical]

서론

이전 글에서 OCI에 Semaphore를 셋업 한 뒤 실제 인프라 전환을 시작하려 하였다.

새 도구의 학습곡선이야 감수할 수 있는 영역이지만, 불현듯 아래와 같은 의심이 생겼다.

IaC를 한답시고 정작 GUI에서 버튼을 누르고 있는데, 이게 맞는 건가?

이번 글에서는 불현듯 스쳐 지나가지 못한 그 의문이 일으킨 나비효과에 대해 다루고자 한다.


본론

OCI + Semaphore 폐기

Semaphore를 만지며 가장 먼저 느낀 것은 학습곡선이었다. Project, Repository, Inventory, Environment, Template 같은 개념이 각기 분리되어 있어 하나의 Playbook을 돌리려면 GUI를 여러 차례 오가며 클릭해야 했다. 익숙해지면 그러려니 할 일이지만, 굳이 익숙해지기 위해 노력을 하고 싶지 않았다.

여기에 더해 가장 결정타가 된 건 도구의 본질적인 모순을 자각했을 때이다.

왜 설계할 때 자각하지 못하고 직접 해보고 나니 자각했을까 하지만 인생이란 게 그런 게 아니겠는가.

Semaphore는 자기 상태(Project, Inventory, Template, Schedule 등)를 내부 BoltDB에 저장한다. 이는 Playbook 코드는 Git에 있지만, 그 Playbook이 어떤 인벤토리에 어떤 환경변수를 가지고 어느 스케줄로 돌아갈지는 Semaphore 안의 BoltDB에 기록된다는 것을 의미한다.

굳이 하자면 Ansible을 통해 코드화할 수 있겠지만, 곰곰이 생각해 보니 한 단계를 더 거쳐 가는 느낌이다.

현재 상황으로만 보면 Semaphore의 경우 진실의 절반은 Git, 절반은 BoltDB에 흩어져 있는 셈이다. Semaphore가 죽으면 BoltDB도 함께 사라지고, Git에는 Playbook 코드만 남으므로 다시 GUI에서 Project를 새로 만들고 Template들을 일일이 다시 등록해야 한다. 자동화 도구(Terraform + Ansible)의 자동화 도구(Semaphore)가 또다시 사람의 손을 요구한다는 뜻이다.

물론 BoltDB를 스토리지로 마운트해서 사용하면 되겠지만,
처음에 마운트 잊어버렸다가 재시작하는 과정에서 한 번 날리고서 문제를 실감했다.

또한 이는 IaC의 핵심 원칙인 Single Source of Truth(SSoT)를 위배한다. SSoT란 모든 데이터 요소를 오직 하나의 출처에서만 관리하여 데이터의 정합성을 보장하는 정보 시스템 설계 방법론을 의미한다. 이를 IaC 관점에서 살펴보면, 인프라의 상태와 모든 설정값은 오직 하나의 진실 원천(Git Repository)에서만 정의되고 파생되어야 한다는 뜻이다.

In information science and information technologysingle source of truth (SSOT) architecture, or single point of truth (SPOT) architecture, for information systems is the practice of structuring information models and associated data schemas such that every data element is mastered (or edited) in only one place, providing data normalization to a canonical form (for example, in database normalization or content transclusion).

...

The advantages of SSOT architectures include easier prevention of mistaken inconsistencies (such as a duplicate value/copy somewhere being forgotten), and greatly simplified version control. Without a SSOT, dealing with inconsistencies implies either complex and error-prone consensus algorithms, or using a simpler architecture that's liable to lose data in the face of inconsistency (the latter may seem unacceptable but it is sometimes a very good choice; it is how most blockchains operate: a transaction is actually final only if it was included in the next block that is mined).

Ideally, SSOT systems provide data that are authentic (and authenticatable), relevant, and referable.


출처 : Single Source of truth, WIKIPEDIA (https://en.wikipedia.org/wiki/Single_source_of_truth)

Github Actions 로의 회귀

Semaphore가 했던 본질적인 역할은 단 하나였다. 외부에 위치한 제어 평면(Control Plane)이 VPN을 통해 홈서버 내부로 진입하여 Terraform/Ansible을 실행한다.

Semaphore GUI를 걷어내고 생각해 보면, 이 역할은 굳이 OCI 위에 띄운 애플리케이션일 필요가 없다. GitHub Actions의 워크플로가 WireGuard Client로 홈 네트워크에 접속한 뒤 그 안에서 Terraform과 Ansible을 실행하면 최초 목표로 하였던 걸 모두 달성할 수 있다.

물론 OCI VM과 달리 Runner가 돌 때마다 WireGuard 설치 > 연결을 매번 새로 하므로 조금 느리긴 하지만 그 정돈 상관없다.

이를 위해 GHA용 WireGuard VPN Peer를 추가해 준 뒤 연결 테스트를 위해 아래와 같이 워크플로우를 작성하였다.

name: 'Proxmox Connectivity Test'

on:
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Setup WireGuard
        run: |
          sudo apt-get install -y wireguard
          sudo tee /etc/wireguard/wg0.conf > /dev/null <<EOF
          [Interface]
          PrivateKey = ${{ secrets.WG_PEER_GHA_PRIVATE_KEY }}
          Address    = ${{ secrets.WG_PEER_GHA_ADDRESS }}

          [Peer]
          PublicKey  = ${{ secrets.WG_HUB_PUBLIC_KEY }}
          Endpoint   = ${{ secrets.WG_HUB_ENDPOINT }}
          AllowedIPs = ${{ secrets.WG_PEER_GHA_ALLOWED_IPS }}
          PersistentKeepalive = 25
          EOF
          sudo wg-quick up wg0

      - name: Verify reachability
        run: ping -c 3 10.80.60.254   # 내부망 (vmbr1) 도달 확인

      - name: Teardown
        if: always()
        run: sudo wg-quick down wg0 || true
Run ping -c 3 10.80.60.254
PING 10.80.60.254 (10.80.60.254) 56(84) bytes of data.
64 bytes from 10.80.60.254: icmp_seq=1 ttl=63 time=229 ms
64 bytes from 10.80.60.254: icmp_seq=2 ttl=63 time=134 ms
64 bytes from 10.80.60.254: icmp_seq=3 ttl=63 time=134 ms

--- 10.80.60.254 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms

테스트 결과 정상적으로 도달함을 확인하였다. GHA Runner를 통해 홈서버에 다이렉트로 접근하게 되면서 OCI VM은 사실상 유물이 되었다. 그렇다고 공짠데 당장 없앨 이유도 없어, 그냥 유기 상태로 남겨두기로 했다. 추후 어딘가 활용할 방안이 있지 않을까 싶다.

시크릿 관리의 난해함

Semaphore를 폐기하고 GHA로 이전하면서, Semaphore가 자체적으로 가지고 있던 Environment(시크릿 저장소) 기능도 함께 사라졌다. 이에 따라 Semaphore에서 관리되던 시크릿들을 GitHub Secrets로 이전하였다.

처음엔 한곳에 모인 게 오히려 깔끔하다고 생각했다. 근데 며칠 굴려보니 답답한 게 한둘이 아니었다. 그중 핵심은 아래 두 가지이다.

  • Write-only
    • 한 번 등록하면 값을 다시 못 본다. 잘못 입력했는지 확인하려면 workflow 를 돌려서 결과로 추론해야 한다.
  • 변경 추적 불가
    • 누가 언제 무엇을 바꿨는지 기록이 안 남는다. 시크릿이 갑자기 잘못 동작해도 원인을 거슬러 올라갈 길이 없다.

가장 큰 문제점은 값을 등록은 했는데 정작 보거나 정리하거나 추적할 수 없다는 것이었다. 물론 Github Secrets가 지향하는 바를 이해 못 하는 건 아니나, 기존 발급했던 시크릿들의 원본을 이미 보관하지 않은지 오래였기에 전체 다 새로 발급받는 과정이 불가피했다.

Github Secrets 일부 | 출처 : 내 Github Repo

본격적인 Secret Manager 도입의 필요성을 느낀 순간이었다.

Infisical 도입

Secret Manager 를 도입하기 전에 원하는 바를 정리했다.

  • 자체 호스팅 가능 (외부 SaaS 의존 회피)
  • GUI 가 직관적
  • GitHub Sync 지원 — GHA 가 자동으로 끌어오도록
  • 학습곡선 낮음 — Semaphore 에서 배운 교훈

후보는 둘이었다.

항목 HashiCorp Vault Infisical
진입장벽 높음 (Policy, Auth method, Secret engine) 낮음 (UI 직관적)
기능 깊이 Dynamic secret, PKI, Transit 등 매우 깊음 핵심 기능 충분, 기업 기능은 유료
GitHub Sync 별도 구성 필요 기본 내장
자체 호스팅 가능 가능 (Docker compose)
라이선스 BSL MIT 코어 + 일부 유료

Vault가 제공하는 기능이 더 많으나, 홈서버 규모에서는 너무 오버 엔지니어링이라고 느꼈다. 단순히 시크릿을 한 곳에 모으고 GitHub Secrets로 자동 sync 한다는 단순한 요구를 가장 깔끔하게 만족시키는 건 Infisical 쪽이었다.

좀 더 가볍기도 했다.

자체 호스팅은 이전 글의 Zero Ingress 철학과 같은 결이다. 시크릿 매니저는 유출되었을 때 사이드 이펙트가 너무 커서 외부로 공개하는 건 아무래도 큰 부담이었다. 때문에 다른 내부 서비스들과 마찬가지로 Cloudflare Tunnel 없이 VPN-only 로 두기로 했다.

다만 자체 호스팅 + VPN-Only로 하는 순간 또 다른 문제점이 벌써 예상된다.

자동화 흐름이 시크릿 매니저에 종속되는 순간, 매니저가 죽으면 자동화도 멈춘다. SaaS면 엔간하면 안 죽겠지만, self-host 면 가용성도 본인 책임이다. 단일 LXC 하나가 죽었다고 GHA workflow 가 통째로 멈추는 구조라면, 시크릿 매니저를 도입한 의미 자체가 무색해진다.

마찬가지로, 이미 많은 선현이 이를 해결하기 위해 GitHub Sync라는 해답지를 제공해 줬다. Github Sync는 Infisical와 실시간 통신을 통해 시크릿을 불러오는 구조가 아닌, Infisical에서 Github Secret로 일정 주기 혹은 특정 이벤트 단위로 밀어 넣는 구조이기에 Infisical LXC가 죽어도 마지막 sync 된 값이 GH Secrets 에 남아 있어 GHA 는 정상 동작한다.

구체적으론 아래와 같이 동작한다.

  1. Infisical UI 에서 시크릿 추가 / 수정
  2. 백그라운드 sync 가 GH Secrets 에 복제
  3. GHA workflow 는 GH Secrets 에서 직접 읽음

Infisical이 필요한 시점은 시크릿 회전·추가하는 순간뿐, 일상 자동화엔 끼지 않는다. 이러면 Infisical은 PBS, pfSense와 같이 필수적인 bootstrap dependency가 아니라 단순 운영 도구로 전락하게 된다.

DR 복구 시 굳이 Infisical LXC 까지 복구할 필요가 없게 된다.

Infisical 배포

이제 드디어 Terraform + Ansible을 실전 도입할 순간이다. 기존 설계한 의도대로, Terraform을 통해 LXC를 정의하고, Ansible을 통해 정의된 LXC의 내부 설정을 진행할 예정이다.

LXC 정의

가장 먼저 Infisical LXC를 아래와 같이 정의하였다.

# terraform/proxmox/infisical.tf


resource "proxmox_virtual_environment_container" "infisical" {
  vm_id = 207
  network_interface { bridge = "vmbr1" }
  initialization {
    ip_config { ipv4 { address = "10.80.60.207/24"; gateway = "10.80.60.1" } }
    user_account { keys = [var.ssh_public_key_gha] }
  }
  features { nesting = true }   # Docker 호환
}

이후 Ansible 쪽은 LXC 안에 Docker + Docker Compose를 깔고, Infisical의 공식 compose 파일을 변형하여 3개의 컨테이너 (backend, postgres, redis) 를 띄우는 역할을 한다. 외부 노출은 80 포트만, 그것도 LAN 안에서만 들리도록 제한했다.

# ansible/roles/infisical/tasks/main.yml


---
- name: Bootstrap python + apt utilities (raw — ansible apt module 의존)
  ansible.builtin.raw: |
    apt-get update -qq
    apt-get install -y python3 python3-apt curl ca-certificates gnupg
  changed_when: false

- name: Ensure Docker apt keyring directory
  ansible.builtin.file:
    path: /etc/apt/keyrings
    state: directory
    mode: '0755'

- name: Download Docker apt signing key
  ansible.builtin.get_url:
    url: https://download.docker.com/linux/debian/gpg
    dest: /etc/apt/keyrings/docker.asc
    mode: '0644'

- name: Configure Docker apt source
  ansible.builtin.copy:
    dest: /etc/apt/sources.list.d/docker.list
    mode: '0644'
    content: |
      deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable

- name: Install Docker engine + compose plugin
  ansible.builtin.apt:
    name:
      - docker-ce
      - docker-ce-cli
      - containerd.io
      - docker-compose-plugin
    state: present
    update_cache: true

- name: Ensure Infisical directory exists
  ansible.builtin.file:
    path: "{{ infisical_data_dir }}"
    state: directory
    mode: '0755'

- name: Render docker-compose.yml
  ansible.builtin.template:
    src: docker-compose.yml.j2
    dest: "{{ infisical_data_dir }}/docker-compose.yml"
    mode: '0644'
  notify: restart infisical

- name: Render .env (시크릿 포함)
  ansible.builtin.template:
    src: env.j2
    dest: "{{ infisical_data_dir }}/.env"
    mode: '0600'
  notify: restart infisical

- name: Ensure Infisical stack is up
  ansible.builtin.shell:
    cmd: docker compose up -d
    chdir: "{{ infisical_data_dir }}"
  changed_when: false

- name: Wait for Infisical API ready
  ansible.builtin.uri:
    url: "http://localhost/api/status"
    status_code: 200
  register: api_status
  until: api_status.status == 200
  retries: 60
  delay: 5

- name: Bootstrap Infisical instance (idempotent — 200 on first, 400/403 if already)
  ansible.builtin.uri:
    url: "http://localhost/api/v1/admin/bootstrap"
    method: POST
    body_format: json
    body:
      email: "{{ infisical_admin_email }}"
      password: "{{ infisical_admin_password }}"
      organization: "{{ infisical_organization_name }}"
    status_code: [200, 400, 403]
  register: bootstrap_result
  changed_when: bootstrap_result.status == 200
  no_log: true

- name: Show bootstrap status
  ansible.builtin.debug:
    msg: >-
      Infisical bootstrap:
      {{ 'CREATED (first time)' if bootstrap_result.status == 200
         else 'ALREADY INITIALIZED (skipping)' }}
# Managed by Ansible (infisical role) — DO NOT EDIT BY HAND

services:
  backend:
    container_name: infisical-backend
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    image: infisical/infisical:{{ infisical_image_tag }}
    pull_policy: always
    env_file: .env
    ports:
      - {{ infisical_listen_port }}:8080
    environment:
      - NODE_ENV=production
    networks:
      - infisical

  redis:
    image: redis:7-alpine
    container_name: infisical-redis
    restart: always
    networks:
      - infisical
    volumes:
      - redis_data:/data

  db:
    container_name: infisical-db
    image: postgres:14-alpine
    restart: always
    env_file: .env
    volumes:
      - pg_data:/var/lib/postgresql/data
    networks:
      - infisical
    healthcheck:
      test: "pg_isready --username=${POSTGRES_USER} && psql --username=${POSTGRES_USER} --list"
      interval: 5s
      timeout: 10s
      retries: 10

volumes:
  pg_data:
    driver: local
  redis_data:
    driver: local

networks:
  infisical:

ansible/roles/infisical/ 에서 LXC 내부에 실제 Docker + Infisical compose를 띄우는 구조이다.

사전 준비

Terraform + Ansible을 통해 스펙을 정의하였더라도 실제 배포 및 적용을 위해선 서버에 접근할 수 있어야 한다. 다만 두 도구 모두 원리가 다르기에 각기 다른 인증 수단을 발급받아야 한다.

Terraform은 Proxmox provider를 거쳐 Proxmox API와 통신한다. "vmid 207 LXC 를 vmbr1 위에 만들어" 같은 명령을, API를 기반으로 수행한다. 즉, Proxmox API를 호출하기 위해 API Token을 발급받아야 한다.

Ansible은 Terraform과 달리 해당 서버/LXC에 SSH로 직접 들어가서 명령을 실행하는 구조다. ​당연하게도, 서버에 접근할 수 있는 SSH 인증이 필요하다. 여러 수단이 있겠지만, 본 글에서는 공개키 기반 SSH 키 페어를 통해 자동화할 예정이다.

도구 통신 경로 필요한 인증
Terraform Provider → Proxmox API API Token
Ansible 서버 내부 SSH SSH 키 페어

Proxmox API Token

Proxmox UI → Datacenter → Permissions → API Tokens에서 발급할 수 있다.

Proxmox API Token 발급 | 출처 : 내 Proxmox

이후 아래처럼 최초 1회만 시크릿을 조회할 수 있기에 반드시 기록해 둬야 한다.

Proxmox API Token Secret | 출처 : 내 Proxmox

신기하게도 Proxmox는 API Token 발급과 그 권한 부여가 분리되어 있다. 아래처럼 발급한 토큰에 권한을 부여해 주어야 실제 자동화가 가능하다.

오히려 토큰 발급과 권한이 분리되어 있기에 추후 유출되어도 권한 회수가 쉬울 것 같다.
Proxmox API Token 권한 부여 | 출처 : 내 Proxmox

SSH 키 페어

ssh-keygen 를 통해 키 페어 한 쌍을 만들 수 있다.

ssh-keygen -t ed25519 -C "gha" -f ./gha_key
  • gha_key — 비공개 키. 절대 외부 노출 X
  • gha_key.pub — 공개 키. 마음껏 노출해도 된다

ed25519 는 RSA보다 가볍고 안전한 최신 알고리즘이다. 새로 만들 거면 굳이 RSA 쓸 이유 없다.

뭐 타원곡선 어쩌구를 통해 안정성이 높다곤 하는데 이해는 못 했다.
Ed25519 - IT 위키

공개 키는 terraform을 통해 LXC를 생성하는 과정에서 var.ssh_public_key_gha로 주입되어 LXC 생성 시점에 ~/.ssh/authorized_keys에 심긴다.

user_account {
  keys = [var.ssh_public_key_gha]
}

이와 동시에 비공개 키는 GHA workflow 가 webfactory/ssh-agentssh-agent에 적재해두고, 이후 ansible-playbook 명령들이 자동으로 비공개 키를 사용하여 LXC 내부에 접속하는 구조이다.

참고로, 비대칭 암호화의 원리는 아래와 같다.

비대칭 암호화는 한 쌍의 키(공개 키와 개인 키)를 만들어 작동합니다. 누구나 공개 키를 사용하여 데이터를 암호화할 수 있습니다. 그러나 개인 키의 소유자만 해당 데이터를 해독할 수 있습니다.

비대칭 암호화의 주요 장점은 대부분의 전문가들이 대칭 암호화의 주요 보안 불안 지점으로 간주하는 보안 키 교환의 필요성을 제거한다는 것입니다.

그러나 비대칭 암호화는 대칭 암호화보다 현저히 느리고 리소스를 많이 사용합니다. 이러한 이유로 조직과 메시징 앱은 보안 키 배포를 위해 비대칭 암호화를 사용하고 후속 데이터 교환을 위해 대칭 암호화를 사용하는 하이브리드 암호화 방법에 점점 더 의존하고 있습니다.


출처 | 비대칭 암호화란 무엇인가요?, https://www.ibm.com/kr-ko/think/topics/asymmetric-encryption

이를 SSH에 입각하여 보면 아래와 같다.

  1. 클라이언트(GHA Runner)가 서버(Host, LXC)에 접속 요청
  2. 서버가 random 한 challenge를 던짐
  3. 클라이언트가 자신의 비공개 키로 challenge에 서명해 돌려줌
  4. 서버가 등록된 공개 키로 서명을 검증
SSH 인증 절차 | 출처 : Generated by Gemeni

Workflow 확장

발급한 인증 정보를 이전 글에 등록했던 것들과 더불어 아래와 같이 GitHub Secrets에 추가하였다.

  • PROXMOX_API_TOKEN — Proxmox API 호출용
  • SSH_GHA_PRIVATE_KEY — LXC 내부 접속용 비공개 키
  • SSH_GHA_PUBLIC_KEY — terraform 변수로 주입할 공개 키

이후 WG 도달 테스트만 하던 기존 워크플로우를 본격적으로 확장한다.

phase 순서는 다음과 같다.

  1. Ansible (호스트) — vmbr1 같은 사전 설정
  2. Terraform — LXC 207 생성 (state backend 는 B2)
  3. Ansible (LXC) — Docker + Infisical 설치 + bootstrap

순서가 중요하다. 호스트의 vmbr1이 없으면 terraform이 만든 LXC 가 그 브릿지에 못 붙고, terraform으로 만든 LXC가 없으면 ansible이 들어갈 대상이 없다.

물론 현시점에서 vmbr1이 없을 린 없겠으나,
추후 DR 상황까지 고려하여 Proxmox Host 설정도 Ansible을 통해 제어하도록 하였다.

다만 한 가지, Phase 1의 ansible이 들어갈 Proxmox 호스트는 Terraform이 만든 LXC가 아니라 베어메탈로 직접 설치된 호스트다. LXC와 달리 공개키를 자동으로 심을 길이 없다. 한 번만 사람이 직접 호스트의 ~root/.ssh/authorized_keys에 공개키를 추가해 주어야 한다. 그 후로는 ansible이 자동으로 들어간다.

현재로선 이 부분까지 자동화하는 방법은 찾지 못했는데 추후 보완해 보도록 하겠다.

확장한 Workflow는 아래와 같다.

name: 'Proxmox'

  on:
    workflow_dispatch:
    push:
      branches: [main]

  jobs:
    deploy:
      runs-on: ubuntu-latest
      env:
        TF_VAR_proxmox_endpoint:   ${{ secrets.PROXMOX_ENDPOINT }}
        TF_VAR_proxmox_api_token:  "root@pam!terraform=${{ secrets.PROXMOX_API_TOKEN }}"
        TF_VAR_ssh_public_key_gha: ${{ secrets.SSH_GHA_PUBLIC_KEY }}
        AWS_ACCESS_KEY_ID:         ${{ secrets.AWS_ACCESS_KEY_ID }}     # B2 backend
        AWS_SECRET_ACCESS_KEY:     ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      steps:
        - uses: actions/checkout@v5

        - name: Setup WireGuard
          run: |
            sudo apt-get install -y wireguard
            sudo tee /etc/wireguard/wg0.conf > /dev/null <<EOF
            [Interface]
            PrivateKey = ${{ secrets.WG_PEER_GHA_PRIVATE_KEY }}
            Address    = ${{ secrets.WG_PEER_GHA_ADDRESS }}

            [Peer]
            PublicKey  = ${{ secrets.WG_HUB_PUBLIC_KEY }}
            Endpoint   = ${{ secrets.WG_HUB_ENDPOINT }}
            AllowedIPs = ${{ secrets.WG_PEER_GHA_ALLOWED_IPS }}
            PersistentKeepalive = 25
            EOF
            sudo wg-quick up wg0

        # === Ansible 셋업 (host / LXC playbook 공유) ===
        - uses: actions/setup-python@v5
          with: { python-version: '3.12' }
        - run: pip install ansible
        - uses: webfactory/[email protected]
          with:
            ssh-private-key: ${{ secrets.SSH_GHA_PRIVATE_KEY }}

        # === Phase 1. Ansible (호스트) ===
        - name: Ansible — Proxmox host
          working-directory: ansible
          run: ansible-playbook playbooks/proxmox_host.yml

        # === Phase 2. Terraform — LXC provisioning ===
        - uses: hashicorp/setup-terraform@v3
          with: { terraform_wrapper: false }

        - name: Terraform Apply
          working-directory: terraform/proxmox
          run: |
            terraform init
            terraform apply -auto-approve

        # === Phase 3. Ansible (LXC 내부 설정) ===
        - name: Ansible — Infisical LXC
          working-directory: ansible
          run: ansible-playbook playbooks/infisical.yml

        # === Cleanup ===
        - name: Teardown WireGuard
          if: always()
          run: sudo wg-quick down wg0 || true

이제 Workflow를 정상적으로 수행하면 아래처럼 배포가 완료된 것을 볼 수 있다.

Infisical 대시보드 | 출처 : 내 Infisical

Infisical 설정

Infisical 배포를 완료하였으니, 본격적으로 Secrets를 연동할 차례이다. 우선 Homelab Repo의 시크릿을 관리하기 위해 별도 프로젝트를 만들어 주었다.

Infisical Project 생성 | 출처 : 내 Infisical

앞서 언급하였다시피 Github Sync를 통해 Infisical이 시크릿을 밀어 넣기 위해선 당연하겠지만 해당 저장소에 접근할 수 있어야 한다. 이를 위해 OAuth, Github App과 같은 수단이 있겠지만, 우선은 가장 간단히 해당 저장소에 접근(읽기, 쓰기)할 수 있는 PAT(Personal Access Token)를 발급받아 등록하였다.

Github PAT 발급 | 출처 : 내 Github

이후 발급받은 토큰 정보를 기반으로 Infisical 내에서 새로운 Connection을 생성해 주었다.

Infisical Connection 생성 | 출처 : 내 Infisical

이후 직전에 생성한 Project 내부로 들어오면 해당 Connection 정보를 기반으로 저장소와 시크릿을 동기화할 수 있다.

Sync를 생성한 뒤 테스트를 위해 아무 환경변수를 시험 삼아 만들어 보면 정상적으로 동기화가 이루어짐을 확인할 수 있다.

Infisical 테스트 환경변수 등록 | 출처 : 내 Infisical
추가된 Test 환경변수 | 출처 : 내 Github

테스트 이후 실제 Workflow 동작에 필요한 환경변수를 모두 이관해 주면 끝이다.

Infisical 환경변수 추가 | 출처 : 내 Infisical


결론

결과적으로 Semaphore에서 GitHub Actions와 Infisical로의 회귀는 단순한 도구 변경이 아니라, IaC의 핵심인 SSoT(단일 진실 공급원) 원칙을 실현하는 과정이었다. GUI에 의존하던(하려고 했던) 반쪽짜리 자동화에서 벗어나, 인프라의 상태와 시크릿 관리까지 모두 코드로 통제하고 투명하게 추적할 수 있는 기반을 마련했다.

비록 초기 구축을 위한 설정과 이해해야 할 구성 요소들은 늘어났으나, 이를 통해 시스템 전체의 가용성과 안정성을 챙길 수 있게 되었다. 이제 Infisical LXC로 테스트하였듯이 본격적으로 준비된 자동화 파이프라인을 바탕으로, 실제 운영할 서비스들을 배포해 나갈 예정이다.


참고자료

Single source of truth - Wikipedia
Terraform Best Practices: State, Security and Reuse [2026] | env zero
A practical guide to Terraform best practices covering state management, naming conventions, security, automation, and reusability, with examples of how…
Terraform으로 proxmox에 VM 생성하기
Terraform에 대해서
비대칭 암호화란 무엇인가요? | IBM
비대칭 암호화는 공개 키와 개인 키라는 두 개의 서로 다른 키를 사용하여 데이터를 암호화하고 해독하는 암호화 방법입니다.
Ed25519 - IT 위키