From 9c4efb72ba46552a7b6fe36f0cd8ee95f33263dd Mon Sep 17 00:00:00 2001 From: Christian Rubio Date: Thu, 30 Jul 2026 23:07:13 -0500 Subject: [PATCH 1/4] chore: replace self-hosted deploy runner with SSH from GitHub-hosted runners soy-chrislo/queres is a personal-account repo, not an org member, so the org's self-hosted runner-group mechanism can never apply here (verified via the GitHub API, not assumed). Rewrites deploy.yml's deploy job to run on ubuntu-latest and reach the VPS over SSH as the existing deploy system user (the same convention four other projects on that VPS already use), makes the GHCR package public to drop docker login entirely, fixes the GHCR image reference in all three compose files, rewrites deploy/README.md's runner section, and records the reversal in ADR-0027. --- .github/workflows/deploy.yml | 208 +++++++++++++------- deploy/README.md | 174 ++++++++-------- deploy/dev/docker-compose.yml | 4 +- deploy/prod/docker-compose.yml | 2 +- deploy/staging/docker-compose.yml | 2 +- docs/adr/ADR-0027-public-repo-ssh-deploy.md | 121 ++++++++++++ 6 files changed, 346 insertions(+), 165 deletions(-) create mode 100644 docs/adr/ADR-0027-public-repo-ssh-deploy.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 90ecee4..63b4587 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,8 +9,32 @@ # merged PR would be arbitrary execution on that machine. Neither of the two # needs anything from the host — only Node/pnpm and the run's own GITHUB_TOKEN # to publish to GHCR. -# - deploy runs self-hosted because its job *is* to act on the target host, and -# for that very reason it never checks out or runs repository code. +# - deploy ALSO runs on GitHub's ephemeral runners (`ubuntu-latest`), not +# self-hosted. This repo (`soy-chrislo/queres`) is a personal-account repo, not a +# member of any GitHub organization — verified via +# `gh api orgs/tunodo-organization/actions/runner-groups` against the org that +# hosts this project's private twin. Org-scoped runner-groups are the only +# mechanism GitHub offers to safely expose a self-hosted runner to a public repo, +# and they don't apply outside org membership: a self-hosted `deploy` job here +# would never be picked up, ever — not unconfigured, structurally impossible for +# a personal-account repo. See ADR-0027 for the full reasoning. +# +# The job still doesn't check out or run repository code — same rationale as +# before: whoever controls the target host's Docker socket controls the host, +# and this job needs that socket, so no repository code should ever execute +# with that privilege. Instead it reaches the VPS over SSH as the existing +# `deploy` system user, the same convention four other projects already +# deployed from this VPS use to deploy from GitHub-hosted runners +# (`github-actions-deploy`, `ciibague-ci-deploy`, `icr-deploy@github-actions`, +# `vps-ovh-deploy` are its other `authorized_keys` entries). This reverses this +# file's original "avoids adding another pair of keys to rotate" rationale for +# `deploy`: that rationale assumed the runner already lived on the host, which +# is no longer true for this repo — the SSH key it was avoiding is now the only +# viable mechanism, not a preference. The `deploy` user's `docker` group +# membership is already root-equivalent on this shared VPS (whoever can reach +# the socket can mount any host path or run a privileged container); this SSH +# key inherits that same risk, unchanged from what the other four projects +# already accept. # # Triggered on every push to staging or main, or manually (workflow_dispatch) against # those same two branches. No other branch or tag is a valid target (see the step @@ -197,23 +221,25 @@ jobs: deploy: name: Deploy en VPS - # The only job that runs in the self-hosted pool, and by design: its job is to act - # on the target host, so it needs its filesystem and its Docker socket. - # - # There's no SSH/SCP involved because the runner already runs on the target machine: - # a network hop back to itself would be indirection with no benefit, and it would - # add another pair of keys to keep and rotate. + # Runs on GitHub's ephemeral runners (`ubuntu-latest`), same as the other two + # jobs — see the "Runner assignment" header comment for why self-hosted is + # structurally unavailable for this repo. It reaches the VPS over SSH as the + # `deploy` system user instead of running directly on the target host. # - # This job does NOT check out the repo and must not acquire it: no repository code - # runs here. The reason is security, not style — whoever controls the Docker socket - # controls the host: they can mount any filesystem path inside a container and spin - # up privileged containers, so socket access is equivalent to root on the machine. - # Running repository code in a job with that access turns any merged change into - # arbitrary execution at that privilege level. That's why this job only consumes two - # artifacts already produced on ephemeral runners — the frontends bundle and the - # image published to GHCR — and applies them. If it ever seems to need something - # from the repo, reconsider the job's design before adding an `actions/checkout`. - runs-on: [self-hosted, linux, deploy-host] + # This job still does NOT check out the repo and must not acquire it: no + # repository code runs here, on this runner or on the VPS. The reason is + # security, not style — whoever controls the target host's Docker socket + # controls the host: they can mount any filesystem path inside a container and + # spin up privileged containers, so socket access is equivalent to root on the + # machine. Running repository code with that access (directly, or indirectly by + # having it dictate what the SSH session executes) turns any merged change into + # arbitrary execution at that privilege level. That's why this job only + # consumes two artifacts already produced on ephemeral runners — the frontends + # bundle and the image published to GHCR — and applies them over SSH using a + # remote script authored here, in the workflow file, never fetched from repo + # content at deploy time. If it ever seems to need something from the repo, + # reconsider the job's design before adding an `actions/checkout`. + runs-on: ubuntu-latest needs: [build-and-push, build-frontends] # Hook point for GitHub Environments: `env_name` already resolves to exactly # `prod` or `staging` (see the step "Determinar ambiente desde rama"). By itself it @@ -223,43 +249,61 @@ jobs: # they're configured. environment: ${{ needs.build-and-push.outputs.env_name }} permissions: - # No `contents: read`: this job doesn't check out or read the repo via API. The - # download of this run's own artifacts uses the runner's runtime token, and the - # GHCR login uses the GITHUB_TOKEN with the only scope declared here. + # No `contents: read`: this job doesn't check out or read the repo via API. It + # doesn't need `packages: read` either anymore — the `docker compose pull` below + # is unauthenticated, because ghcr.io/soy-chrislo/queres is a public package + # (ADR-0027), so there's no GHCR login left in this job at all. The declaration + # is left in place as a no-op rather than removed; tightening it further isn't + # part of this change's scope. packages: read steps: - - name: Limpiar artefactos de corridas previas - # A self-hosted runner's workspace persists between runs. Without this rm, the - # download below would mix with files from a previous deploy, and old assets - # could get copied into the served directory. - run: | - set -euo pipefail - # `set -u` aborts if GITHUB_WORKSPACE weren't *defined*, but not if it were - # defined and empty: in that case the `rm -rf` below would degenerate into - # `rm -rf /frontends`. Same explicit guard as IMAGE_TAG/DEPLOY_PATH in the - # deploy step. - : "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE no debe estar vacio}" - rm -rf "$GITHUB_WORKSPACE/frontends" - - name: Descargar artefactos de frontends uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: frontends path: ./frontends - - name: Login en GHCR - # Uses the run's own ephemeral GITHUB_TOKEN (same mechanism as build-and-push) - # instead of a long-lived credential stored on the host: it lives only as long - # as the job, its scope is the one declared above (packages: read), and it - # doesn't depend on the personal environment of the user running the runner. - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Configurar acceso SSH a la VPS + # Host key pinned from a secret captured once via `ssh-keyscan` (see + # deploy/README.md), never fetched live in this step — that would be + # trust-on-first-use, exactly what StrictHostKeyChecking=yes below exists to + # prevent. Raw ssh/scp are used instead of a third-party composite action + # (e.g. appleboy/ssh-action): SHA-pinning such an action doesn't audit the + # binary it wraps, and appleboy/ssh-action doesn't verify the host key unless + # `fingerprint` is explicitly passed (defaults to trusting whatever key the + # host presents). + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }} + run: | + set -euo pipefail + : "${SSH_PRIVATE_KEY:?SSH_PRIVATE_KEY secret no debe estar vacio}" + : "${KNOWN_HOSTS:?KNOWN_HOSTS secret no debe estar vacio}" + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts - - name: Desplegar en VPS + - name: Copiar frontends a la VPS + # Only the two dist/ trees are copied — not the whole $DEPLOY_PATH — for the + # same defense-in-depth reason as before: `.env.prod` and `keys/private.pem` + # also live on the host and this step has no business touching them. + env: + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_USER: ${{ secrets.SSH_USER }} + TMP_DIR: /home/deploy/tmp-frontends-${{ needs.build-and-push.outputs.env_name }} + run: | + set -euo pipefail + : "${SSH_HOST:?SSH_HOST secret no debe estar vacio}" + : "${SSH_USER:?SSH_USER secret no debe estar vacio}" + SSH_OPTS=(-i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts -o StrictHostKeyChecking=yes) + ssh "${SSH_OPTS[@]}" "$SSH_USER@$SSH_HOST" "mkdir -p '$TMP_DIR'" + scp -r "${SSH_OPTS[@]}" ./frontends/landing ./frontends/web "$SSH_USER@$SSH_HOST:$TMP_DIR/" + + - name: Desplegar en VPS por SSH # IMAGE_TAG pins the image to the immutable per-commit tag that build-and-push # just published, instead of the mutable -latest tag: between the push and # this pull, another image could have been published over -latest, and the @@ -268,32 +312,35 @@ jobs: # so they don't need to be touched; the guard below fails loudly if any of them # still don't. # - # Assets are copied using a disposable alpine container with a bind-mount of - # $DEPLOY_PATH/www, instead of writing directly from the runner's shell: the - # deploy directory belongs to a different system user and the runner has no - # direct write access there. Only `www/` is mounted, not the whole $DEPLOY_PATH, - # because `.env.prod` and that environment's `keys/private.pem` also live in - # there, and this container has no business with them. It's defense in depth, - # not a hard boundary (whoever reaches this job already has the Docker socket), - # but it limits the blast radius of a scripting error. `docker compose` does run - # directly, because it only needs the socket and to read the compose file. + # The whole remote sequence runs as a single SSH command so a network drop + # mid-script can't leave the deploy half-applied in a way this job wouldn't + # notice: the ssh exit code reflects the remote script's own `set -euo + # pipefail` exit code. env: + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_USER: ${{ secrets.SSH_USER }} DEPLOY_PATH: ${{ needs.build-and-push.outputs.deploy_path }} IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }} + TMP_DIR: /home/deploy/tmp-frontends-${{ needs.build-and-push.outputs.env_name }} run: | set -euo pipefail # An empty IMAGE_TAG breaks both protections at once: compose would fall back to # its default `${IMAGE_TAG:--latest}` (unpinned deploy), and the - # `*":$IMAGE_TAG"*` pattern in the guard below would degenerate into `*":"*`, + # `*":$IMAGE_TAG"*` pattern in the remote guard would degenerate into `*":"*`, # which matches almost any image reference and would report success. Cut this - # off before anything else. + # off before anything else. Passed as positional args to the remote script + # (not interpolated into the ssh command string) so nothing here depends on + # these values being shell-metacharacter-free. + : "${SSH_HOST:?SSH_HOST secret no debe estar vacio}" + : "${SSH_USER:?SSH_USER secret no debe estar vacio}" : "${IMAGE_TAG:?IMAGE_TAG no debe estar vacio}" : "${DEPLOY_PATH:?DEPLOY_PATH no debe estar vacio}" - # An empty GITHUB_WORKSPACE would make the bind-mount below mount /frontends (a - # host path that normally doesn't exist): the `rm -rf` of the served assets - # would already have run inside the container by the time `cp` failed on an - # empty source. - : "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE no debe estar vacio}" + ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts -o StrictHostKeyChecking=yes \ + "$SSH_USER@$SSH_HOST" bash -s -- "$DEPLOY_PATH" "$IMAGE_TAG" "$TMP_DIR" <<'REMOTE_SCRIPT' + set -euo pipefail + DEPLOY_PATH="$1" + IMAGE_TAG="$2" + TMP_DIR="$3" cd "$DEPLOY_PATH" # Verifies that the host's compose actually resolved the image to the # immutable tag. Without this, a compose that doesn't interpolate IMAGE_TAG @@ -309,7 +356,7 @@ jobs: # worse than a deploy that doesn't happen. stderr isn't silenced either — the # real reason for the failure has to stay visible in the job log. if ! resolved="$(docker compose -f docker-compose.prod.yml config --images)"; then - echo "::error::No se pudo resolver el compose de $DEPLOY_PATH (docker compose config --images falló; el error concreto está arriba en el log). Deploy abortado sin modificar nada." + echo "::error::No se pudo resolver el compose de $DEPLOY_PATH (docker compose config --images fallo; el error concreto esta arriba en el log). Deploy abortado sin modificar nada." exit 1 fi case "$resolved" in @@ -317,22 +364,25 @@ jobs: echo "Imagen fijada a $IMAGE_TAG" ;; *) - echo "::error::El compose de $DEPLOY_PATH no resolvió la imagen a $IMAGE_TAG (resuelto: $resolved). Revisar que use \${IMAGE_TAG:--latest}." + echo "::error::El compose de $DEPLOY_PATH no resolvio la imagen a $IMAGE_TAG (resuelto: $resolved). Revisar que use \${IMAGE_TAG:--latest}." exit 1 ;; esac - # The pull also runs BEFORE touching the assets, for the same reason as the - # guard: it's non-destructive, and if the image can't be fetched (registry - # down, tag deleted by retention, network) the job aborts with the old - # frontends still in place. If it ran after the swap, a failed pull would - # leave new assets served against the previous backend — a version skew until - # the next successful deploy. + # Unauthenticated: ghcr.io/soy-chrislo/queres is a public package + # (ADR-0027), so no docker login is needed on this host at all — no + # long-lived registry credential left behind in ~/.docker/config.json. + # The pull runs BEFORE touching the assets, same reason as the guard + # above: it's non-destructive, and if the image can't be fetched + # (registry down, tag deleted by retention, network) the job aborts with + # the old frontends still in place. docker compose -f docker-compose.prod.yml pull # From here on the step is destructive to the served directory. # The daemon creates $DEPLOY_PATH/www if it doesn't exist (root:root), the - # same as the `mkdir -p` inside the container used to do before. + # same as the `mkdir -p` inside the container used to do before. Source is + # the temp dir scp'd in the previous step, not $GITHUB_WORKSPACE — this + # runner is no longer the target host. docker run --rm \ - -v "$GITHUB_WORKSPACE/frontends:/artifacts:ro" \ + -v "$TMP_DIR:/artifacts:ro" \ -v "$DEPLOY_PATH/www:/deploy/www" \ alpine:3 sh -c ' set -eu @@ -346,3 +396,19 @@ jobs: # possible. docker compose -f docker-compose.prod.yml up -d --remove-orphans docker image prune -f + REMOTE_SCRIPT + + - name: Limpiar directorio temporal en VPS + # if: always() — must run even if an earlier step failed, so a bad deploy + # doesn't leave stale frontend artifacts on the host. Best-effort: if the SSH + # key was never written (failure before "Configurar acceso SSH"), this would + # itself fail: `|| true` keeps that from masking the real earlier failure with + # a second, unrelated one. + if: always() + env: + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_USER: ${{ secrets.SSH_USER }} + TMP_DIR: /home/deploy/tmp-frontends-${{ needs.build-and-push.outputs.env_name }} + run: | + ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts -o StrictHostKeyChecking=yes \ + "$SSH_USER@$SSH_HOST" "rm -rf '$TMP_DIR'" || true diff --git a/deploy/README.md b/deploy/README.md index 063de7c..4b22b0e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -27,11 +27,12 @@ deploy/ > "Determine environment from branch" step of `.github/workflows/deploy.yml` (which produces the > `DEPLOY_PATH` the job `cd`s into), the `alias`/`root` directives of the three `nginx-site.conf` > files, and the commands in this README. `deploy.yml` also isn't a drop-in workflow for another -> reason: its `deploy` job requires the `[self-hosted, linux, deploy-host]` labels, which only -> exist on the maintainer's runner — a fork that doesn't change them leaves the job queued -> forever. If a fork wants a different layout, the search-and-replace of the three paths must go -> **together with** that of the label; changing only one leaves the deploy pointing at a directory -> that doesn't exist. +> reason: its `deploy` job reaches the target host over SSH as the `deploy` system user (see +> "SSH deploy credential" below) — a fork needs its own keypair, its own `SSH_HOST`/`SSH_USER` +> Environment secrets, and that key added to *its own* target host's `authorized_keys`. If a fork +> wants a different layout, the search-and-replace of the three paths must go **together with** +> provisioning its own SSH credential; changing only one leaves the deploy pointing at a directory +> that doesn't exist, or authenticating against a host it was never granted access to. | Subfolder | Path on VPS | Port | Domain | |---|---|---|---| @@ -127,13 +128,15 @@ cp deploy/dev/docker-compose.yml /opt/auth-gateway-dev/docker-compose.prod.yml Repeat with `deploy/staging/` → `/opt/auth-gateway-staging/` and `deploy/prod/` → `/opt/auth-gateway/`. -**2. Replace `CHANGEME_GITHUB_ORG` with the real GHCR namespace** +**2. Verify the GHCR namespace matches your fork** -The template references `ghcr.io/CHANGEME_GITHUB_ORG/auth-gateway:${IMAGE_TAG:--latest}` +The three compose files already reference `ghcr.io/soy-chrislo/queres:${IMAGE_TAG:--latest}` (in `dev/` the default is `dev-local`, because there's no dev image in GHCR — see the dev -subsection below). That namespace must match the one the workflow publishes to, which is -`ghcr.io//` (deploy.yml derives it from `github.repository`, without hardcoding any -organization). In a fork with the repository renamed, the repo name changes too. +subsection below) — that's this repo's own namespace, matching what `deploy.yml` publishes to: +`ghcr.io//` (derived from `github.repository`, without hardcoding any organization). +If you're bringing up a **fork** under a different account or repo name, replace +`ghcr.io/soy-chrislo/queres` in all three files with your fork's own `ghcr.io//` — +otherwise the host pulls (or tries to pull) images from this repo's namespace instead of yours. Don't touch the `${IMAGE_TAG:-...}` interpolation: it's what lets the deploy job pin the image to the immutable per-commit tag. The workflow verifies that the host's compose resolves it to the @@ -167,7 +170,7 @@ git -C ~/src/auth-gateway fetch origin && git -C ~/src/auth-gateway checkout dev git -C ~/src/auth-gateway pull --ff-only # 2. Build with the same image name the compose file expects -docker build -t ghcr.io//auth-gateway:dev-local ~/src/auth-gateway +docker build -t ghcr.io/soy-chrislo/queres:dev-local ~/src/auth-gateway # 3. Bring it up (no pull) cd /opt/auth-gateway-dev @@ -188,103 +191,94 @@ CI doesn't copy dev's frontends either: they have to be built (`pnpm --filter @q build`, `pnpm --filter auth-gateway-web build`) and their `dist/` folders copied to `/opt/auth-gateway-dev/www/landing` and `/opt/auth-gateway-dev/www/web` by hand. -## Dedicated runner group for deploy (mandatory before making the repo public) +## SSH deploy credential (the `deploy` VPS user) + +The `deploy` job in `.github/workflows/deploy.yml` runs on GitHub's ephemeral runners +(`ubuntu-latest`), not self-hosted. It was self-hosted originally, but that mechanism is +**structurally unavailable** for this repo: `soy-chrislo/queres` is a personal-account repo, not a +member of any GitHub organization. Org-scoped runner groups — the only mechanism GitHub offers to +safely expose a self-hosted runner to a public repo (`allows_public_repositories`, +`restricted_to_workflows`) — don't apply outside org membership. There's no fix that keeps the +runner self-hosted; see ADR-0027 for the full reasoning and the alternatives that were ruled out. + +Instead, the `deploy` job reaches the VPS over SSH as the host's existing `deploy` system user +(uid 1001, groups `deploy` + `docker`). This isn't a new mechanism invented for this repo — four +other projects on the same VPS already deploy this way from GitHub-hosted runners, each with their +own key in `deploy`'s `authorized_keys` (`github-actions-deploy`, `ciibague-ci-deploy`, +`icr-deploy@github-actions`, `vps-ovh-deploy`). This repo adds a fifth, additive entry rather than +sharing one of the existing four. + +> **Risk carried by this key, stated explicitly.** The `deploy` user's `docker` group membership +> is already root-equivalent on this shared VPS: whoever holds a working key can mount any host +> path into a container or run one privileged, which is full host compromise, not just +> "deploy access." This SSH key inherits that same risk — unchanged from what the other four +> projects already accept, but worth stating rather than silently inheriting. Guard the private +> key accordingly: Environment-scoped secrets only (never repo-level), never logged, never printed +> in a workflow step. + +**1. Generate a dedicated keypair** -The `deploy` job in `.github/workflows/deploy.yml` is the only one that runs on the self-hosted -pool (`[self-hosted, linux, deploy-host]`). Today that pool lives in an organization runner group -with `allows_public_repositories: false`, so **the day this repository goes public, deploy jobs -stop being picked up and stay queued indefinitely**. Enabling public repos on the current group -doesn't work either: it would expose those runners to every public repo assigned to it. +```bash +ssh-keygen -t ed25519 -C "queres-deploy@github-actions" -f ./queres_deploy_key -N "" +``` -The correct setup is a new runner group, exclusive to this repository and this workflow. Requires -organization admin permissions. Run the steps in order, substituting ``. +A key of its own, not shared with the other four projects — so it can be revoked without touching +theirs. -**1. Create the group restricted to selected repos and with public repos enabled** +**2. Add the public half to the VPS, additive only** ```bash -gh api -X POST "orgs//actions/runner-groups" \ - -f name='auth-gateway-deploy' \ - -f visibility='selected' \ - -F allows_public_repositories=true \ - -F restricted_to_workflows=true \ - -f "selected_workflows[]=/auth-gateway/.github/workflows/deploy.yml@refs/heads/main" \ - -f "selected_workflows[]=/auth-gateway/.github/workflows/deploy.yml@refs/heads/staging" +# On the VPS, as a user that can write to /home/deploy/.ssh/authorized_keys +cat ./queres_deploy_key.pub >> /home/deploy/.ssh/authorized_keys ``` -- `visibility: selected` — only the repos added in step 2 can use the group. -- `allows_public_repositories: true` — what keeps the deploy from freezing once the repo is - published. -- `restricted_to_workflows` + `selected_workflows` — the group only serves **that** workflow on - **those** two refs. A modified `ci.yml`, a new workflow, or `deploy.yml` itself running on a PR - branch can't claim these runners even if they request the correct labels. +Append, never overwrite — the file already holds four other projects' keys. -Note the `id` the call returns; the following steps need it as ``. - -**2. Authorize only this repository** +**3. Capture the host key once, don't fetch it live in the workflow** ```bash -REPO_ID=$(gh api "repos//auth-gateway" --jq '.id') - -gh api -X PUT "orgs//actions/runner-groups//repositories" \ - -F "selected_repository_ids[]=$REPO_ID" +ssh-keyscan mail.tunodo.net > ./queres_known_hosts ``` -`PUT` replaces the entire list, it doesn't append: after this call the group has exactly one repo. +The workflow's `ssh`/`scp` calls use `-o StrictHostKeyChecking=yes` against this pinned value — +fetching it live inside the job (e.g. via `ssh-keyscan` in a step) would be trust-on-first-use, +which defeats the point of pinning it at all. -**3. Move the deploy runners to the new group** +**4. Store as Environment-scoped secrets, not repo-level** -```bash -# List the runners and locate the current pool's by their labels -gh api "orgs//actions/runners" \ - --jq '.runners[] | {id, name, labels: [.labels[].name]}' +Four secrets, set identically on **both** the `prod` and `staging` GitHub Environments of this +repo (`Settings → Environments → → Environment secrets`): -# Move each runner to the group (repeat for every ). -# There's no PATCH on the runner: the assignment happens from the group's side. -gh api -X PUT "orgs//actions/runner-groups//runners/" -``` +| Secret | Value | +|---|---| +| `SSH_HOST` | `mail.tunodo.net` | +| `SSH_USER` | `deploy` | +| `SSH_PRIVATE_KEY` | contents of `./queres_deploy_key` (the private half) | +| `KNOWN_HOSTS` | contents of `./queres_known_hosts` | -A runner belongs to only one group at a time, so this `PUT` removes it from the previous group. If -that same host also serves other repos in the organization, **don't move it**: spin up an -additional runner dedicated to this deploy (with the same labels) and move only that one, so the -other repos aren't left without a runner. +Environment-scoped (not repo-level) so the credential is only exposed to jobs that declare +`environment: prod` / `environment: staging` — exactly the `deploy` job, nothing else in this +repo's workflows. Delete the local key files after this step; they don't need to exist anywhere +but GitHub's secret store and the VPS's `authorized_keys`. -**4. Verify before publishing the repo** +**5. Make the GHCR package public** -```bash -# selected_workflows lives inside the group object; there's no separate endpoint for it. -gh api "orgs//actions/runner-groups/" \ - --jq '{name, visibility, allows_public_repositories, restricted_to_workflows, selected_workflows}' +A brand-new GHCR package defaults to **private** even when its parent repo is public — do this +before the first real deploy attempt, not after, or `docker compose pull` on the VPS fails with +`unauthorized`. From the package's own settings page +(`https://github.com/users/soy-chrislo/packages/container/queres/settings`, only visible after at +least one image has been pushed once): **Change visibility → Public**. This is what lets the +`deploy` job's `docker compose pull` run with no `docker login` at all — see ADR-0027 for why that +was chosen over authenticating the pull. -gh api "orgs//actions/runner-groups//repositories" --jq '.repositories[].full_name' -gh api "orgs//actions/runner-groups//runners" --jq '.runners[].name' -``` +**6. (Recommended) GitHub Environments gate — already wired, just needs enabling** -Expected: `visibility=selected`, `allows_public_repositories=true`, `restricted_to_workflows=true`, -both `deploy.yml` entries in `selected_workflows`, a single repository, and the deploy runners -present. - -> **Verify the `selected_workflows` format empirically.** The API stores whatever it's given -> without validating that the workflow exists, and the REST docs don't pin down the exact ref -> format. The `GET` above confirms the value was stored, **not** that it matches. The real -> functional test, with the repo still private, is two runs: -> -> 1. A push (or `workflow_dispatch`) to `staging` should claim the runner and complete the -> `deploy` job. -> 2. A different test workflow, with the same `[self-hosted, linux, deploy-host]` labels, should -> stay **queued with no runner** and never run. -> -> If (1) stays queued, the ref format didn't match: retry with a `PATCH` on the group trying the -> other form (`...deploy.yml@refs/heads/main` vs `...deploy.yml@main`) and repeat the test. -> If (2) runs, the restriction isn't active and the group still isn't safe for a public repo. - -**5. (Recommended) GitHub Environments gate — already wired, just needs enabling** - -Complementary to, not a replacement for, the above. The `deploy` job **already declares** -`environment: ${{ needs.build-and-push.outputs.env_name }}`, which resolves to `prod` on pushes to -`main` and to `staging` on pushes to `staging`. On its own that **protects nothing**: if the -environment doesn't exist, GitHub creates it on the fly with no rules and the deploy proceeds all -the same. What declaring it buys is the hook point — turning on the protection later never -requires editing the workflow again. +The `deploy` job **already declares** `environment: ${{ needs.build-and-push.outputs.env_name }}`, +which resolves to `prod` on pushes to `main` and to `staging` on pushes to `staging` — the same +declaration that scopes the secrets above. On its own that **protects nothing** beyond secret +scoping: if the environment doesn't exist, GitHub creates it on the fly with no rules and the +deploy proceeds all the same. What declaring it buys is the hook point — turning on the protection +later never requires editing the workflow again. To enable it, under `Settings → Environments` of the repository, on both `prod` and `staging`: @@ -296,8 +290,8 @@ To enable it, under `Settings → Environments` of the repository, on both `prod - **Wait timer** (optional) — a window to abort a deploy that was just triggered. > Same plan prerequisite as branch protections: on **private** repositories, environment -> protection rules require GitHub Team/Enterprise. On **public** repositories they're available at -> no cost. If this repo goes public, enable them right away. +> protection rules require GitHub Team/Enterprise. This repo is already public, so they're +> available at no cost — enable them. > **Note on `deploy.yml` and the `dev` branch.** The workflow only accepts `refs/heads/main` and > `refs/heads/staging`; `dev` was intentionally removed because it's the only branch without diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index f8118d8..7ab6079 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -3,10 +3,10 @@ services: # A diferencia de staging/prod, este default NO apunta a una imagen de GHCR: el workflow de # deploy solo publica tags `staging-*` y `prod-*`, asi que `dev-latest` no existe (ni se # actualiza) en el registry. `dev-local` es la imagen que se construye a mano en el host con - # `docker build -t ghcr.io//auth-gateway:dev-local .` — ver deploy/README.md, seccion + # `docker build -t ghcr.io/soy-chrislo/queres:dev-local .` — ver deploy/README.md, seccion # "Ambiente de dev: no hay imagen en GHCR". Para correr codigo ya publicado en este stack, # exportar IMAGE_TAG con un tag que si exista (p.ej. `staging-sha-`). - image: ghcr.io/CHANGEME_GITHUB_ORG/auth-gateway:${IMAGE_TAG:-dev-local} + image: ghcr.io/soy-chrislo/queres:${IMAGE_TAG:-dev-local} container_name: auth-gateway-dev restart: unless-stopped ports: diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index 32df03d..ece5628 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -1,6 +1,6 @@ services: auth-gateway: - image: ghcr.io/CHANGEME_GITHUB_ORG/auth-gateway:${IMAGE_TAG:-prod-latest} + image: ghcr.io/soy-chrislo/queres:${IMAGE_TAG:-prod-latest} container_name: auth-gateway restart: unless-stopped ports: diff --git a/deploy/staging/docker-compose.yml b/deploy/staging/docker-compose.yml index 566aebf..f0b126b 100644 --- a/deploy/staging/docker-compose.yml +++ b/deploy/staging/docker-compose.yml @@ -1,6 +1,6 @@ services: auth-gateway-staging: - image: ghcr.io/CHANGEME_GITHUB_ORG/auth-gateway:${IMAGE_TAG:-staging-latest} + image: ghcr.io/soy-chrislo/queres:${IMAGE_TAG:-staging-latest} container_name: auth-gateway-staging restart: unless-stopped ports: diff --git a/docs/adr/ADR-0027-public-repo-ssh-deploy.md b/docs/adr/ADR-0027-public-repo-ssh-deploy.md new file mode 100644 index 0000000..0bb48e7 --- /dev/null +++ b/docs/adr/ADR-0027-public-repo-ssh-deploy.md @@ -0,0 +1,121 @@ +# ADR-0027: SSH-Based Deploy for the Public Repo, Replacing the Self-Hosted Runner + +2026-07-30 · **Implemented** + +## Context + +`soy-chrislo/queres` is the public OSS release of the private `tunodo-organization/auth-gateway` +(published 2026-07-30). Its `.github/workflows/deploy.yml` was copied over from the private repo +verbatim, including the `deploy` job's `runs-on: [self-hosted, linux, deploy-host]`. That job never +ran: zero runners with those labels are, or ever can be, registered against this repo. + +This isn't a configuration gap — it's structural. `soy-chrislo/queres` is a **personal-account** +repo, not a member of `tunodo-organization`. The org's self-hosted pool (4 `vps-tunodo` runners in +one `Default` runner group, `allows_public_repositories: false`) is only reachable by repos that +belong to the org — confirmed live via `gh api orgs/tunodo-organization/actions/runner-groups`. +Org-scoped runner groups (`visibility: selected` + `allows_public_repositories: true` + +`restricted_to_workflows`) are the only mechanism GitHub offers to safely expose a self-hosted +runner to a public repo, and — per GitHub's own runner-group model — they don't extend to repos +outside the organization, no matter how the group is configured. `deploy/README.md`'s own +documented mitigation for "the day this repo goes public" (a dedicated runner group with +`allows_public_repositories: true`) assumed the repo would stay inside the org. It's an org-only +feature and cannot apply to a personal-account repo. There is no version of "register a self-hosted +runner here" that works. + +The repo owner decided this deploy targets the **same** VPS (`mail.tunodo.net`) and the **same** +already-live containers the private repo's pipeline deploys today (`auth-gateway` on :3200, +`auth-gateway-staging` on :3220, paths `/opt/auth-gateway(-staging)`) — not an isolated environment +— and that the private repo's pipeline is retired entirely once this one is proven, making +`queres` the sole thing that deploys to those containers going forward. The only intentional +behavior change is GitHub-hosted runners instead of self-hosted (free for public repos); paths, +ports, container/network names, and branch flow (`dev → staging → main`) all stay as they were. + +The original `deploy` job's header comment justified self-hosted with "no SSH/SCP involved because +the runner already runs on the target machine... avoids adding another pair of keys to keep and +rotate." That rationale no longer holds for this repo: the assumption it depended on (the runner +already lives on the host) is false here, so the alternative it was avoiding — an SSH key — is now +the only viable mechanism, not a preference being traded off. + +Investigation found a directly-reusable precedent already live on this exact VPS: a dedicated +`deploy` system user (uid 1001, groups `deploy` + `docker`) whose `~/.ssh/authorized_keys` already +holds four other projects' CI keys (`github-actions-deploy`, `ciibague-ci-deploy`, +`icr-deploy@github-actions`, `vps-ovh-deploy`) — i.e. multiple other repos on this infrastructure +already deploy via SSH from GitHub-hosted runners, not self-hosted ones. + +## Decision + +Adopt the VPS's existing per-project SSH deploy-user convention instead of inventing a new +mechanism: + +- `deploy.yml`'s `deploy` job runs on `ubuntu-latest` (same as the other two jobs) and reaches the + VPS over SSH as the existing `deploy` system user. A fifth `authorized_keys` entry + (`queres-deploy@github-actions`) is added, additive — the existing four are untouched. +- Raw `ssh`/`scp` are used, not a third-party composite action (`appleboy/ssh-action` / + `scp-action`). The VPS host key is captured once via `ssh-keyscan` and stored as a `KNOWN_HOSTS` + secret, checked with `-o StrictHostKeyChecking=yes` — real verification, not trust-on-first-use. +- `ghcr.io/soy-chrislo/queres` is made a **public** GHCR package. This removes `docker login` from + the `deploy` job entirely: `docker compose pull` on the VPS is unauthenticated. +- Credentials (`SSH_HOST`, `SSH_USER`, `SSH_PRIVATE_KEY`, `KNOWN_HOSTS`) are stored as + Environment-scoped secrets on **both** `prod` and `staging`, not repo-level — only jobs that + declare `environment: prod|staging` (i.e. exactly `deploy`) can read them. +- Cutover to production is staged, not a single flip: disable the private repo's `deploy.yml` + trigger first (so it can't race the new pipeline); dispatch the new pipeline against `staging` + before touching the live `docker-compose.prod.yml` (expected to fail at `pull`, proving the + SSH/scp mechanics and the guard logic without risk); confirm the image is pullable by hand; + `.bak` the live compose file before editing it; re-dispatch and verify `staging` end-to-end + (health check + login flow); only then repeat into `main`/prod, withholding `docker image + prune -f` on prod until that's confirmed healthy, so the previous image stays available for an + instant rollback. The private repo's pipeline is retired only after `main` is verified — dropping + both its trigger and its now-pointless `[self-hosted, linux, deploy-host]` job, not just the + trigger. + +`dev`/`staging` branches on the public repo are created from the public repo's own `main`, never +from the private repo's real `dev`/`staging`. Confirmed via `git merge-base` on the local checkout +that used to hold both remotes: the public repo's history is a deliberately sanitized, squashed +snapshot (3 commits) of the private repo (311 commits at the time of publication) — copying the +private repo's actual branches would leak unreviewed internal history into the public repo. + +## Rejected Alternatives + +- **A dedicated org runner group with `allows_public_repositories: true`, restricted to this + repo's `deploy.yml`** (the mitigation `deploy/README.md` documented before this ADR): requires + the repo to be an org member. `soy-chrislo/queres` is a personal-account repo — this path is + categorically unavailable, not merely unconfigured. +- **`docker login` over SSH using `GITHUB_TOKEN`**: an `appleboy/ssh-action` env-injected token is + readable via `/proc//cmdline` on a shared host for the step's duration, and a `docker login` + with no matching `docker logout` leaves a long-lived credential in `~/.docker/config.json` on the + VPS — exactly the kind of standing credential this design otherwise avoids. Rejected in favor of + making the GHCR package public, which needs no registry credential on the host at all. +- **`appleboy/ssh-action` / `appleboy/scp-action`**: doesn't verify the host key unless + `fingerprint` is explicitly passed (defaults to trusting whatever key the host presents). + SHA-pinning the action doesn't audit the binary it wraps. Rejected in favor of raw `ssh`/`scp` + with an explicit, pinned `known_hosts` secret — the same trust model as a person deploying by + hand from a known machine. +- **Editing the live `docker-compose.prod.yml` before proving the new image is pullable**: leaves a + window, however brief, where a manual `docker compose up` during an unrelated incident fails + outright because the file points at an image that doesn't exist yet in the new namespace. + Rejected in favor of the staged cutover in the Decision above. + +## Consequences + +- ✅ Unblocks deploy for the public repo without depending on org membership or GitHub product + changes — the only structurally available mechanism, not a preference among alternatives. +- ✅ Reuses a pattern already proven on this VPS by four other projects, instead of inventing a new + one — smaller review surface, no new operational pattern for whoever maintains the VPS. +- ✅ No long-lived registry credential is left on the VPS: the GHCR pull is unauthenticated, and the + only standing credential this design adds is the SSH key itself. +- ⚠️ **A new SSH key is a new root-equivalent credential on a shared VPS.** The `deploy` user's + `docker` group membership is already root-equivalent (whoever can reach the Docker socket can + mount any host path or run a privileged container) — this key inherits that same risk, unchanged + from what the other four projects using this convention already accept. It is not eliminated by + this design, only handled with the same care as its siblings: Environment-scoped secrets, never + repo-level; never logged; revocable independently of the other four keys. +- ⚠️ The GHCR package for this repo's image must be public. Verified safe before flipping visibility: + the Dockerfile's runtime stage only copies `dist/`, `node_modules/`, `package.json`, and + `prisma/` — no secrets are baked into the image layers. +- ℹ️ Retiring the private repo's pipeline requires removing both its push trigger and its + `[self-hosted, linux, deploy-host]` job — leaving the label in place after removing only the + trigger would leave a dead job definition referencing infrastructure this repo no longer uses. +- ℹ️ `dev`/`staging` on the public repo start from a clean, sanitized `main`, not from the private + repo's real branches — anyone comparing history between the two repos should expect a much + shorter, curated log on the public side, by design, not as a sign of a broken mirror. From a7fff0b4bf317189af08147e16e8191ebf531fca Mon Sep 17 00:00:00 2001 From: Christian Rubio Date: Thu, 30 Jul 2026 23:21:15 -0500 Subject: [PATCH 2/4] fix: address code-reviewer + security-reviewer findings on the SSH deploy diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop naming the real VPS hostname and other, unrelated projects' SSH key identifiers in the new public docs (deploy.yml header, deploy/README.md, ADR-0027) — those details don't belong in a public repo and weren't this project's to disclose. - Converge all three ssh/scp remote-command constructions in deploy.yml on the same safe pattern (a single, already-quoted command string) instead of relying on ssh's unquoted joining of trailing arguments for one of them; the prior comment claimed a safety property that pattern didn't actually provide, even though today's fixed values aren't exploitable. - Pre-clean the remote frontends temp dir before every scp, since scp -r merges into an existing directory rather than mirroring it. - Document the new SCP-to-temp-dir frontend transfer mechanism in ADR-0027, which the prior version omitted. --- .github/workflows/deploy.yml | 50 ++++++++++++------- deploy/README.md | 54 +++++++++++---------- docs/adr/ADR-0027-public-repo-ssh-deploy.md | 40 +++++++++------ 3 files changed, 87 insertions(+), 57 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 63b4587..f05ae3f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,19 +22,21 @@ # The job still doesn't check out or run repository code — same rationale as # before: whoever controls the target host's Docker socket controls the host, # and this job needs that socket, so no repository code should ever execute -# with that privilege. Instead it reaches the VPS over SSH as the existing -# `deploy` system user, the same convention four other projects already -# deployed from this VPS use to deploy from GitHub-hosted runners -# (`github-actions-deploy`, `ciibague-ci-deploy`, `icr-deploy@github-actions`, -# `vps-ovh-deploy` are its other `authorized_keys` entries). This reverses this -# file's original "avoids adding another pair of keys to rotate" rationale for -# `deploy`: that rationale assumed the runner already lived on the host, which -# is no longer true for this repo — the SSH key it was avoiding is now the only -# viable mechanism, not a preference. The `deploy` user's `docker` group -# membership is already root-equivalent on this shared VPS (whoever can reach -# the socket can mount any host path or run a privileged container); this SSH -# key inherits that same risk, unchanged from what the other four projects -# already accept. +# with that privilege. Instead it reaches the VPS over SSH as an existing +# dedicated deploy system user, the same convention several other, unrelated +# projects already deployed from this same VPS use to deploy from GitHub-hosted +# runners (real hostname/username and the other projects' key identifiers are +# deliberately not spelled out here — this is a public repo and that account is +# shared with other tenants' infrastructure; see deploy/README.md and the +# maintainer's private notes). This reverses this file's original "avoids +# adding another pair of keys to rotate" rationale for `deploy`: that rationale +# assumed the runner already lived on the host, which is no longer true for +# this repo — the SSH key it was avoiding is now the only viable mechanism, not +# a preference. The deploy user's `docker` group membership is already +# root-equivalent on this shared VPS (whoever can reach the socket can mount +# any host path or run a privileged container); this SSH key inherits that same +# risk, unchanged from what the other projects sharing that account already +# accept. # # Triggered on every push to staging or main, or manually (workflow_dispatch) against # those same two branches. No other branch or tag is a valid target (see the step @@ -291,6 +293,12 @@ jobs: # Only the two dist/ trees are copied — not the whole $DEPLOY_PATH — for the # same defense-in-depth reason as before: `.env.prod` and `keys/private.pem` # also live on the host and this step has no business touching them. + # + # TMP_DIR is removed and recreated, not just mkdir -p'd: `scp -r` merges + # into an existing remote directory rather than mirroring it, so if the + # final cleanup step of some earlier run didn't complete (network blip, + # cancelled job), a stale file from that run would otherwise survive + # alongside this run's fresh build instead of being replaced by it. env: SSH_HOST: ${{ secrets.SSH_HOST }} SSH_USER: ${{ secrets.SSH_USER }} @@ -300,7 +308,7 @@ jobs: : "${SSH_HOST:?SSH_HOST secret no debe estar vacio}" : "${SSH_USER:?SSH_USER secret no debe estar vacio}" SSH_OPTS=(-i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts -o StrictHostKeyChecking=yes) - ssh "${SSH_OPTS[@]}" "$SSH_USER@$SSH_HOST" "mkdir -p '$TMP_DIR'" + ssh "${SSH_OPTS[@]}" "$SSH_USER@$SSH_HOST" "rm -rf '$TMP_DIR' && mkdir -p '$TMP_DIR'" scp -r "${SSH_OPTS[@]}" ./frontends/landing ./frontends/web "$SSH_USER@$SSH_HOST:$TMP_DIR/" - name: Desplegar en VPS por SSH @@ -328,15 +336,21 @@ jobs: # its default `${IMAGE_TAG:--latest}` (unpinned deploy), and the # `*":$IMAGE_TAG"*` pattern in the remote guard would degenerate into `*":"*`, # which matches almost any image reference and would report success. Cut this - # off before anything else. Passed as positional args to the remote script - # (not interpolated into the ssh command string) so nothing here depends on - # these values being shell-metacharacter-free. + # off before anything else. : "${SSH_HOST:?SSH_HOST secret no debe estar vacio}" : "${SSH_USER:?SSH_USER secret no debe estar vacio}" : "${IMAGE_TAG:?IMAGE_TAG no debe estar vacio}" : "${DEPLOY_PATH:?DEPLOY_PATH no debe estar vacio}" + # ssh joins any trailing command-line arguments with plain spaces (no + # re-quoting) before sending them to the remote shell — passing + # DEPLOY_PATH/IMAGE_TAG/TMP_DIR as separate trailing arguments to `ssh` + # would rely on them never containing a shell metacharacter to round-trip + # safely. Building the remote command as a single, already-quoted string + # with `%q` avoids that assumption entirely — same pattern the other two + # ssh/scp steps in this job already use for their own remote commands. + REMOTE_CMD=$(printf 'bash -s -- %q %q %q' "$DEPLOY_PATH" "$IMAGE_TAG" "$TMP_DIR") ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts -o StrictHostKeyChecking=yes \ - "$SSH_USER@$SSH_HOST" bash -s -- "$DEPLOY_PATH" "$IMAGE_TAG" "$TMP_DIR" <<'REMOTE_SCRIPT' + "$SSH_USER@$SSH_HOST" "$REMOTE_CMD" <<'REMOTE_SCRIPT' set -euo pipefail DEPLOY_PATH="$1" IMAGE_TAG="$2" diff --git a/deploy/README.md b/deploy/README.md index 4b22b0e..152ed5d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -201,20 +201,22 @@ safely expose a self-hosted runner to a public repo (`allows_public_repositories `restricted_to_workflows`) — don't apply outside org membership. There's no fix that keeps the runner self-hosted; see ADR-0027 for the full reasoning and the alternatives that were ruled out. -Instead, the `deploy` job reaches the VPS over SSH as the host's existing `deploy` system user -(uid 1001, groups `deploy` + `docker`). This isn't a new mechanism invented for this repo — four -other projects on the same VPS already deploy this way from GitHub-hosted runners, each with their -own key in `deploy`'s `authorized_keys` (`github-actions-deploy`, `ciibague-ci-deploy`, -`icr-deploy@github-actions`, `vps-ovh-deploy`). This repo adds a fifth, additive entry rather than -sharing one of the existing four. - -> **Risk carried by this key, stated explicitly.** The `deploy` user's `docker` group membership -> is already root-equivalent on this shared VPS: whoever holds a working key can mount any host -> path into a container or run one privileged, which is full host compromise, not just -> "deploy access." This SSH key inherits that same risk — unchanged from what the other four -> projects already accept, but worth stating rather than silently inheriting. Guard the private -> key accordingly: Environment-scoped secrets only (never repo-level), never logged, never printed -> in a workflow step. +Instead, the `deploy` job reaches the VPS over SSH as the host's existing dedicated deploy system +user (member of the host's `docker` group). This isn't a new mechanism invented for this repo — +several other, unrelated projects on the same VPS already deploy this way from GitHub-hosted +runners, each with their own key in that account's `authorized_keys`. This repo adds one more, +additive entry rather than sharing one of the existing ones — real hostname, username, and the +other keys' identifiers are intentionally not spelled out in this public repo (this account is +shared with other tenants' infrastructure who didn't opt into being named here); the maintainer +has them recorded privately. + +> **Risk carried by this key, stated explicitly.** The deploy user's `docker` group membership is +> already root-equivalent on this shared VPS: whoever holds a working key can mount any host path +> into a container or run one privileged, which is full host compromise, not just "deploy access." +> This SSH key inherits that same risk — unchanged from what the other projects sharing that +> account already accept, but worth stating rather than silently inheriting. Guard the private key +> accordingly: Environment-scoped secrets only (never repo-level), never logged, never printed in a +> workflow step. **1. Generate a dedicated keypair** @@ -222,27 +224,29 @@ sharing one of the existing four. ssh-keygen -t ed25519 -C "queres-deploy@github-actions" -f ./queres_deploy_key -N "" ``` -A key of its own, not shared with the other four projects — so it can be revoked without touching -theirs. +A key of its own, not shared with the other projects on that account — so it can be revoked +without touching theirs. **2. Add the public half to the VPS, additive only** ```bash -# On the VPS, as a user that can write to /home/deploy/.ssh/authorized_keys -cat ./queres_deploy_key.pub >> /home/deploy/.ssh/authorized_keys +# On the VPS, as a user that can write to 's authorized_keys +cat ./queres_deploy_key.pub >> ~/.ssh/authorized_keys ``` -Append, never overwrite — the file already holds four other projects' keys. +Append, never overwrite — the file already holds other projects' keys. **3. Capture the host key once, don't fetch it live in the workflow** ```bash -ssh-keyscan mail.tunodo.net > ./queres_known_hosts +ssh-keyscan > ./queres_known_hosts ``` -The workflow's `ssh`/`scp` calls use `-o StrictHostKeyChecking=yes` against this pinned value — -fetching it live inside the job (e.g. via `ssh-keyscan` in a step) would be trust-on-first-use, -which defeats the point of pinning it at all. +`` is the same host that serves the domains in the table above — see the maintainer's +private notes for the real value. The workflow's `ssh`/`scp` calls use +`-o StrictHostKeyChecking=yes` against this pinned value — fetching it live inside the job (e.g. +via `ssh-keyscan` in a step) would be trust-on-first-use, which defeats the point of pinning it at +all. **4. Store as Environment-scoped secrets, not repo-level** @@ -251,8 +255,8 @@ repo (`Settings → Environments → → Environment secrets`): | Secret | Value | |---|---| -| `SSH_HOST` | `mail.tunodo.net` | -| `SSH_USER` | `deploy` | +| `SSH_HOST` | the real VPS hostname (see maintainer's private notes) | +| `SSH_USER` | the real deploy account name (see maintainer's private notes) | | `SSH_PRIVATE_KEY` | contents of `./queres_deploy_key` (the private half) | | `KNOWN_HOSTS` | contents of `./queres_known_hosts` | diff --git a/docs/adr/ADR-0027-public-repo-ssh-deploy.md b/docs/adr/ADR-0027-public-repo-ssh-deploy.md index 0bb48e7..1ed12ce 100644 --- a/docs/adr/ADR-0027-public-repo-ssh-deploy.md +++ b/docs/adr/ADR-0027-public-repo-ssh-deploy.md @@ -22,13 +22,15 @@ documented mitigation for "the day this repo goes public" (a dedicated runner gr feature and cannot apply to a personal-account repo. There is no version of "register a self-hosted runner here" that works. -The repo owner decided this deploy targets the **same** VPS (`mail.tunodo.net`) and the **same** -already-live containers the private repo's pipeline deploys today (`auth-gateway` on :3200, +The repo owner decided this deploy targets the **same** VPS and the **same** already-live +containers the private repo's pipeline deploys today (`auth-gateway` on :3200, `auth-gateway-staging` on :3220, paths `/opt/auth-gateway(-staging)`) — not an isolated environment — and that the private repo's pipeline is retired entirely once this one is proven, making `queres` the sole thing that deploys to those containers going forward. The only intentional behavior change is GitHub-hosted runners instead of self-hosted (free for public repos); paths, ports, container/network names, and branch flow (`dev → staging → main`) all stay as they were. +(Real hostname, deploy account, and secret values are intentionally not spelled out anywhere in +this public repo — see deploy/README.md and the maintainer's private notes.) The original `deploy` job's header comment justified self-hosted with "no SSH/SCP involved because the runner already runs on the target machine... avoids adding another pair of keys to keep and @@ -37,10 +39,11 @@ already lives on the host) is false here, so the alternative it was avoiding — the only viable mechanism, not a preference being traded off. Investigation found a directly-reusable precedent already live on this exact VPS: a dedicated -`deploy` system user (uid 1001, groups `deploy` + `docker`) whose `~/.ssh/authorized_keys` already -holds four other projects' CI keys (`github-actions-deploy`, `ciibague-ci-deploy`, -`icr-deploy@github-actions`, `vps-ovh-deploy`) — i.e. multiple other repos on this infrastructure -already deploy via SSH from GitHub-hosted runners, not self-hosted ones. +deploy system user (member of the host's `docker` group) whose `authorized_keys` already holds +several other, unrelated projects' CI keys — i.e. multiple other repos on this infrastructure +already deploy via SSH from GitHub-hosted runners, not self-hosted ones. Those other projects' +identifiers aren't named here; they share this VPS but have nothing to do with `queres`, and didn't +opt into being referenced in this public repo's docs. ## Decision @@ -48,11 +51,17 @@ Adopt the VPS's existing per-project SSH deploy-user convention instead of inven mechanism: - `deploy.yml`'s `deploy` job runs on `ubuntu-latest` (same as the other two jobs) and reaches the - VPS over SSH as the existing `deploy` system user. A fifth `authorized_keys` entry - (`queres-deploy@github-actions`) is added, additive — the existing four are untouched. + VPS over SSH as the existing deploy system user. A new `authorized_keys` entry + (`queres-deploy@github-actions`) is added, additive — the other projects' entries are untouched. - Raw `ssh`/`scp` are used, not a third-party composite action (`appleboy/ssh-action` / `scp-action`). The VPS host key is captured once via `ssh-keyscan` and stored as a `KNOWN_HOSTS` secret, checked with `-o StrictHostKeyChecking=yes` — real verification, not trust-on-first-use. +- The frontend build artifacts (`apps/landing/dist`, `apps/web/dist`), previously available + directly on the self-hosted runner's own filesystem, now travel over `scp` to a per-environment + temp directory on the VPS (`/home/deploy/tmp-frontends-`) before the remote script bind-mounts + it into the same disposable `alpine` container that swaps served assets. That temp directory is + removed at the end of every run (`if: always()`, best-effort) — the equivalent, on the VPS side, + of the workspace-cleanup the old self-hosted job used to do for its own persistent workdir. - `ghcr.io/soy-chrislo/queres` is made a **public** GHCR package. This removes `docker login` from the `deploy` job entirely: `docker compose pull` on the VPS is unauthenticated. - Credentials (`SSH_HOST`, `SSH_USER`, `SSH_PRIVATE_KEY`, `KNOWN_HOSTS`) are stored as @@ -100,16 +109,19 @@ private repo's actual branches would leak unreviewed internal history into the p - ✅ Unblocks deploy for the public repo without depending on org membership or GitHub product changes — the only structurally available mechanism, not a preference among alternatives. -- ✅ Reuses a pattern already proven on this VPS by four other projects, instead of inventing a new - one — smaller review surface, no new operational pattern for whoever maintains the VPS. +- ✅ Reuses a pattern already proven on this VPS by other, unrelated projects, instead of inventing + a new one — smaller review surface, no new operational pattern for whoever maintains the VPS. - ✅ No long-lived registry credential is left on the VPS: the GHCR pull is unauthenticated, and the only standing credential this design adds is the SSH key itself. -- ⚠️ **A new SSH key is a new root-equivalent credential on a shared VPS.** The `deploy` user's +- ⚠️ **A new SSH key is a new root-equivalent credential on a shared VPS.** The deploy user's `docker` group membership is already root-equivalent (whoever can reach the Docker socket can mount any host path or run a privileged container) — this key inherits that same risk, unchanged - from what the other four projects using this convention already accept. It is not eliminated by - this design, only handled with the same care as its siblings: Environment-scoped secrets, never - repo-level; never logged; revocable independently of the other four keys. + from what the other projects sharing that account already accept. It is not eliminated by this + design, only handled with the same care as its siblings: Environment-scoped secrets, never + repo-level; never logged; revocable independently of the other keys on that account. Compromising + any one of those other, unrelated pipelines yields the same account and the same Docker-socket + access as compromising this one, and vice versa — a pre-existing property of sharing the account, + not something this change introduces, but worth stating plainly rather than only implied. - ⚠️ The GHCR package for this repo's image must be public. Verified safe before flipping visibility: the Dockerfile's runtime stage only copies `dist/`, `node_modules/`, `package.json`, and `prisma/` — no secrets are baked into the image layers. From 9832d5d717cc64836180dc6e47b8ee7dcb6ddd24 Mon Sep 17 00:00:00 2001 From: Christian Rubio Date: Thu, 30 Jul 2026 23:28:29 -0500 Subject: [PATCH 3/4] fix: stop hardcoding the deploy account's home path in deploy.yml TMP_DIR was still /home/deploy/tmp-frontends- after the previous redaction pass -- flagged by a re-review as still disclosing the real deploy account name via a standard /home/ convention, and it was also being restated in prose in ADR-0027. Make TMP_DIR a path relative to the deploy account's home directory instead (ssh/scp both resolve relative remote paths against $HOME by convention). The remote script resolves it back to an absolute path via $HOME before using it in `docker run -v`, since Docker treats a bind-mount source with no leading / as a named-volume name to create, not a bind mount -- silently mounting an empty volume instead of the scp'd files if left relative there. --- .github/workflows/deploy.yml | 22 ++++++++++++++++----- docs/adr/ADR-0027-public-repo-ssh-deploy.md | 12 +++++++---- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f05ae3f..8c0caa7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -294,7 +294,13 @@ jobs: # same defense-in-depth reason as before: `.env.prod` and `keys/private.pem` # also live on the host and this step has no business touching them. # - # TMP_DIR is removed and recreated, not just mkdir -p'd: `scp -r` merges + # TMP_DIR is a relative path, not an absolute one under a hardcoded + # /home/ — both ssh's remote command and scp's destination resolve a + # relative path against the deploy account's home directory by convention, + # so this works exactly the same without baking the real account name into + # a public workflow file. + # + # It's also removed and recreated, not just mkdir -p'd: `scp -r` merges # into an existing remote directory rather than mirroring it, so if the # final cleanup step of some earlier run didn't complete (network blip, # cancelled job), a stale file from that run would otherwise survive @@ -302,7 +308,7 @@ jobs: env: SSH_HOST: ${{ secrets.SSH_HOST }} SSH_USER: ${{ secrets.SSH_USER }} - TMP_DIR: /home/deploy/tmp-frontends-${{ needs.build-and-push.outputs.env_name }} + TMP_DIR: tmp-frontends-${{ needs.build-and-push.outputs.env_name }} run: | set -euo pipefail : "${SSH_HOST:?SSH_HOST secret no debe estar vacio}" @@ -329,7 +335,7 @@ jobs: SSH_USER: ${{ secrets.SSH_USER }} DEPLOY_PATH: ${{ needs.build-and-push.outputs.deploy_path }} IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }} - TMP_DIR: /home/deploy/tmp-frontends-${{ needs.build-and-push.outputs.env_name }} + TMP_DIR: tmp-frontends-${{ needs.build-and-push.outputs.env_name }} run: | set -euo pipefail # An empty IMAGE_TAG breaks both protections at once: compose would fall back to @@ -354,7 +360,13 @@ jobs: set -euo pipefail DEPLOY_PATH="$1" IMAGE_TAG="$2" - TMP_DIR="$3" + # $3 is relative (see the env comment above) — resolved against $HOME here, + # on the host itself, rather than ever hardcoding an absolute /home/ + # path in the workflow source. Needed as an absolute path below: `docker + # run -v` treats a source with no leading `/` as a *named volume* to + # create, not a bind mount, which would silently mount an empty volume + # instead of the actual scp'd files. + TMP_DIR="$HOME/$3" cd "$DEPLOY_PATH" # Verifies that the host's compose actually resolved the image to the # immutable tag. Without this, a compose that doesn't interpolate IMAGE_TAG @@ -422,7 +434,7 @@ jobs: env: SSH_HOST: ${{ secrets.SSH_HOST }} SSH_USER: ${{ secrets.SSH_USER }} - TMP_DIR: /home/deploy/tmp-frontends-${{ needs.build-and-push.outputs.env_name }} + TMP_DIR: tmp-frontends-${{ needs.build-and-push.outputs.env_name }} run: | ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts -o StrictHostKeyChecking=yes \ "$SSH_USER@$SSH_HOST" "rm -rf '$TMP_DIR'" || true diff --git a/docs/adr/ADR-0027-public-repo-ssh-deploy.md b/docs/adr/ADR-0027-public-repo-ssh-deploy.md index 1ed12ce..2a1192b 100644 --- a/docs/adr/ADR-0027-public-repo-ssh-deploy.md +++ b/docs/adr/ADR-0027-public-repo-ssh-deploy.md @@ -58,10 +58,14 @@ mechanism: secret, checked with `-o StrictHostKeyChecking=yes` — real verification, not trust-on-first-use. - The frontend build artifacts (`apps/landing/dist`, `apps/web/dist`), previously available directly on the self-hosted runner's own filesystem, now travel over `scp` to a per-environment - temp directory on the VPS (`/home/deploy/tmp-frontends-`) before the remote script bind-mounts - it into the same disposable `alpine` container that swaps served assets. That temp directory is - removed at the end of every run (`if: always()`, best-effort) — the equivalent, on the VPS side, - of the workspace-cleanup the old self-hosted job used to do for its own persistent workdir. + temp directory under the deploy account's home directory on the VPS (referenced as a path + relative to that account, never a hardcoded `/home/` in the workflow source — same + redaction discipline as the rest of this ADR) before the remote script resolves it to an + absolute path and bind-mounts it into the same disposable `alpine` container that swaps served + assets. That temp directory is removed and recreated before every run (`scp -r` merges into an + existing remote directory rather than mirroring it) and removed again at the end of every run + (`if: always()`, best-effort) — the equivalent, on the VPS side, of the workspace-cleanup the old + self-hosted job used to do for its own persistent workdir. - `ghcr.io/soy-chrislo/queres` is made a **public** GHCR package. This removes `docker login` from the `deploy` job entirely: `docker compose pull` on the VPS is unauthenticated. - Credentials (`SSH_HOST`, `SSH_USER`, `SSH_PRIVATE_KEY`, `KNOWN_HOSTS`) are stored as From 7affe9765b68b4ae350704fbd19b87c112f77ec2 Mon Sep 17 00:00:00 2001 From: Christian Rubio Date: Thu, 30 Jul 2026 23:36:16 -0500 Subject: [PATCH 4/4] fix: stop naming the real VPS deploy account in prose, not just in paths A third review pass found the previous redaction (hostname, sibling project key labels, /home/ path) missed three prose sentences that still spelled out the real account name directly (backticked `deploy`, distinct from the GitHub Actions job also named `deploy:`, which is fine to disclose since it's defined in this same public file). Reworded to refer to it generically, consistent with the rest of the redaction. --- .github/workflows/deploy.yml | 4 ++-- deploy/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8c0caa7..d486754 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -225,8 +225,8 @@ jobs: name: Deploy en VPS # Runs on GitHub's ephemeral runners (`ubuntu-latest`), same as the other two # jobs — see the "Runner assignment" header comment for why self-hosted is - # structurally unavailable for this repo. It reaches the VPS over SSH as the - # `deploy` system user instead of running directly on the target host. + # structurally unavailable for this repo. It reaches the VPS over SSH as a + # dedicated deploy account instead of running directly on the target host. # # This job still does NOT check out the repo and must not acquire it: no # repository code runs here, on this runner or on the VPS. The reason is diff --git a/deploy/README.md b/deploy/README.md index 152ed5d..cbb381b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -27,7 +27,7 @@ deploy/ > "Determine environment from branch" step of `.github/workflows/deploy.yml` (which produces the > `DEPLOY_PATH` the job `cd`s into), the `alias`/`root` directives of the three `nginx-site.conf` > files, and the commands in this README. `deploy.yml` also isn't a drop-in workflow for another -> reason: its `deploy` job reaches the target host over SSH as the `deploy` system user (see +> reason: its `deploy` job reaches the target host over SSH as a dedicated deploy account (see > "SSH deploy credential" below) — a fork needs its own keypair, its own `SSH_HOST`/`SSH_USER` > Environment secrets, and that key added to *its own* target host's `authorized_keys`. If a fork > wants a different layout, the search-and-replace of the three paths must go **together with** @@ -191,7 +191,7 @@ CI doesn't copy dev's frontends either: they have to be built (`pnpm --filter @q build`, `pnpm --filter auth-gateway-web build`) and their `dist/` folders copied to `/opt/auth-gateway-dev/www/landing` and `/opt/auth-gateway-dev/www/web` by hand. -## SSH deploy credential (the `deploy` VPS user) +## SSH deploy credential (the VPS deploy account) The `deploy` job in `.github/workflows/deploy.yml` runs on GitHub's ephemeral runners (`ubuntu-latest`), not self-hosted. It was self-hosted originally, but that mechanism is