diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..991c2d8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,45 @@ +name: Deploy to EC2 + +on: + push: + branches: + - main + workflow_run: + workflows: ["Build and push GHCR images"] + types: + - completed + branches: + - main + workflow_dispatch: + +# Serialize deploys so two SSH sessions never race `docker compose up -d` +# against each other. A push to main fires both triggers above (once +# immediately, once again after the image finishes building) - queueing +# rather than cancelling means the second run still lands on the +# freshly-pushed image even if the first one raced ahead of it. +concurrency: + group: deploy-ec2 + cancel-in-progress: false + +jobs: + deploy: + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - name: Set up deploy key + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }} + + - name: Trust deploy host key + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + # The deploy user's authorized_keys pins this key to a forced command + # (config/deploy/deploy.sh on the instance), so the command given here + # is ignored - whatever SSH runs is whatever that forced command is. + - name: Redeploy + run: ssh -o StrictHostKeyChecking=yes "${{ vars.DEPLOY_SSH_USER }}@${{ vars.DEPLOY_SSH_HOST }}" true diff --git a/README.md b/README.md index 137fcc3..e82029d 100644 --- a/README.md +++ b/README.md @@ -231,11 +231,13 @@ the GUI/API, broker, and backend. It requires: [Cesium Access Token and Assets](#cesium-access-token-and-assets) above) `compose.server.yml` and `compose.worker.yml` pull the `tatc-server` and -`tatc-worker` images from GHCR rather than building them locally. Set -`TATC_SERVER_IMAGE_TAG` / `TATC_WORKER_IMAGE_TAG` to a commit SHA (as pushed by the -[Build and push GHCR images](.github/workflows/ghcr-push-image.yml) -workflow on merge to `main`) to pin the deployed version; both default to -`latest` if unset. +`tatc-worker` images from GHCR (built and pushed by +[Build and push GHCR images](.github/workflows/ghcr-push-image.yml) on every +merge to `main`) rather than building them locally. Leave +`TATC_SERVER_IMAGE_TAG` / `TATC_WORKER_IMAGE_TAG` unset (or `latest`) so +[Auto-deploy](#auto-deploy) picks up new images automatically, or pin them in +`.env` to a specific commit SHA for a reproducible, rollback-able deploy that +auto-deploy will then keep redeploying rather than moving off of. The server's user-account database is stored in the `tatc-data` Docker volume; `compose.server.yml` sets `TATC_DATABASE_URL` to place it there @@ -276,6 +278,54 @@ Replace `username` and `password` with the actual `BACKEND_USER` and once populated with real credentials — avoid committing a version containing production passwords to version control. +### Auto-deploy + +`.github/workflows/deploy.yml` redeploys the server host over SSH: +- on every push to `main` (picks up compose/config changes), and +- after `ghcr-push-image.yml` finishes pushing new images (picks up + server/worker code changes, after the images are actually available in + GHCR rather than racing them). + +Both triggers run the same thing: `config/deploy/deploy.sh` on the instance, +which does `git merge --ff-only` then `docker compose -f +compose.server.yml pull && ... up -d`. It fails loudly rather than +overwriting anything if the checkout has diverged from `main`. + +Because GitHub-hosted runners don't have a small, stable IP range, this means +opening inbound port 22 on the instance broadly rather than allowlisting +GitHub. To keep that safe, the deploy key is restricted with a forced +command so it can never be used for anything but running that one script, +even though the workflow only ever asks it to: + +1. On the EC2 instance, in the deploy user's `~/.ssh/authorized_keys` + (that user needs passwordless `docker`/`docker compose` access and a + checkout of this repo with `.env` already in place): + + ``` + command="/path/to/repo/config/deploy/deploy.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... + ``` + +2. Generate a dedicated deploy keypair (don't reuse a personal key) and add + the public half as shown above: + + ```bash + ssh-keygen -t ed25519 -f deploy_key -N "" -C "tatc-app-deploy" + ``` + +3. Capture the instance's host key from a trusted channel (e.g. the EC2 + console's system log, or a `ssh-keyscan` run over a connection you already + trust) - don't just accept whatever a first connection presents, since + that defeats host verification entirely: + + ```bash + ssh-keyscan -H > known_hosts + ``` + +4. In the repo's GitHub Actions settings, add: + - Repository variables: `DEPLOY_SSH_HOST`, `DEPLOY_SSH_USER` + - Repository secrets: `DEPLOY_SSH_KEY` (private half of `deploy_key`), + `DEPLOY_SSH_KNOWN_HOSTS` (contents of `known_hosts`) + ## License This project is distributed under the [BSD 3-Clause License](LICENSE). diff --git a/config/deploy/deploy.sh b/config/deploy/deploy.sh new file mode 100755 index 0000000..72f5714 --- /dev/null +++ b/config/deploy/deploy.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +# Invoked on the EC2 instance via a forced SSH command (see README) so the +# deploy key can only ever run this script. Assumes it lives at +# /config/deploy/deploy.sh in a checkout that already has a working +# `.env` in place. +cd "$(dirname "$0")/../.." + +git fetch origin main +git merge --ff-only origin/main + +docker compose -f compose.server.yml pull +docker compose -f compose.server.yml up -d +docker image prune -f