From 3a79c3d6c1e8f3a863d1379e6f6391ccc8927db9 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Thu, 27 Aug 2026 14:54:21 +0200 Subject: [PATCH] Add Docker deployment via Gitea Actions host-mode runner Containerize the app (Dockerfile, docker-compose.yaml) and add a CI deploy job that syncs the checkout to /srv/docker/maptool and runs docker compose directly, using the runner's new "host" execution label to reach the host's Docker daemon and filesystem. --- .dockerignore | 5 ++ .gitea/workflows/ci.yml | 36 ++++++++++++ Dockerfile | 17 ++++++ config/puma.rb | 7 ++- docker-compose.yaml | 15 +++++ docs/deployment.adoc | 124 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yaml create mode 100644 docs/deployment.adoc diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..be1d862 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +data +.gitea +.claude +.ruby-lsp diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 66bd247..703eec2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,3 +29,39 @@ jobs: - name: JS syntax check run: find public/js -name "*.js" -print0 | xargs -0 -n1 node --check + + # Runs directly in the runner's own container (see the "host" label in the + # runner's config.yaml), not in an ephemeral per-job container — that's + # what gives this job access to /var/run/docker.sock (already mounted into + # the runner container) and to /srv/docker (bind-mounted there too, see + # docs/deployment.adoc). No systemd/sudo involved. + deploy: + needs: check + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: host + steps: + - uses: actions/checkout@v4 + + # /srv/docker/maptool is the same path inside the runner container and + # on the real host (1:1 bind mount) — required so the relative bind + # mount `./data:/app/data` in docker-compose.yaml resolves to the + # correct real host path when the daemon creates the container. + # + # No rsync in the runner image, so this clears out everything in the + # target except data/ (uploaded GPX files, offline.pmtiles — never + # part of the git checkout in the first place, since it's gitignored) + # and copies the fresh checkout over, dropping .git afterwards. + - name: Sync to /srv/docker/maptool + run: | + mkdir -p /srv/docker/maptool + for f in /srv/docker/maptool/*; do + [ "$(basename "$f")" = "data" ] && continue + rm -rf "$f" + done + cp -a . /srv/docker/maptool/ + rm -rf /srv/docker/maptool/.git + + - name: Deploy + run: | + cd /srv/docker/maptool + docker compose -p maptool up --build -d diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f33866 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM ruby:4.0.6-slim-bookworm + +# build-essential: puma has a native (C) extension; harmless if the +# platform-specific gem in Gemfile.lock already has a precompiled binary +# matching this base image, but keeps the build working if it doesn't. +RUN apt-get update && apt-get install --no-install-recommends -y build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Gemfile Gemfile.lock ./ +RUN bundle install + +COPY . . + +EXPOSE 4567 +CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"] diff --git a/config/puma.rb b/config/puma.rb index 757acf8..aab4c9b 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,4 +1,9 @@ -bind "tcp://127.0.0.1:#{ENV.fetch('PORT', 4567)}" +# 127.0.0.1 by default: local-only, not exposed on the network (see +# docs/architecture.adoc). The Docker deployment overrides this to 0.0.0.0 +# via BIND_HOST — inside a container, binding to loopback only would make +# the app unreachable through the published port; Docker's own port +# publishing is the network boundary there instead. +bind "tcp://#{ENV.fetch('BIND_HOST', '127.0.0.1')}:#{ENV.fetch('PORT', 4567)}" environment ENV.fetch("RACK_ENV", "development") diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..6b566e3 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,15 @@ +services: + maptool: + build: . + restart: unless-stopped + ports: + - "4567:4567" + environment: + BIND_HOST: 0.0.0.0 + RACK_ENV: production + volumes: + # Uploaded GPX files and the offline PMTiles extract — kept outside + # the image so a rebuild/redeploy doesn't wipe them (see + # docs/development.adoc for how the deploy job treats this + # directory). + - ./data:/app/data diff --git a/docs/deployment.adoc b/docs/deployment.adoc new file mode 100644 index 0000000..1e5da41 --- /dev/null +++ b/docs/deployment.adoc @@ -0,0 +1,124 @@ += Deployment +:toc: +:toclevels: 2 + +MapTool deploys as a Docker container on the same server the Gitea Actions +runner runs on. The `deploy` job in `.gitea/workflows/ci.yml` runs directly +inside the runner's own container (see "The runner's host label" below) and +calls `docker compose` directly against the host's Docker daemon — no +systemd unit is involved for this app. + +== Layout on the server + +`/srv/docker/maptool/` holds: + +* A checkout of this repository (kept in sync by the deploy job, see + below) — `Dockerfile`, `docker-compose.yaml`, application code. +* `data/`, *not* part of that sync — uploaded GPX files and + `offline.pmtiles`. Persisted via a bind mount (`./data:/app/data` in + `docker-compose.yaml`), so a redeploy (`docker compose up --build`) + never touches it. + +== The runner's "host" label + +The Gitea Actions runner (`gitea/runner:3`) normally runs each job in a +fresh, isolated container spawned via its mounted +`/var/run/docker.sock` — that's how the `check` job runs (`runs-on: +ubuntu-latest`). Such a job container has no access to the host +filesystem or to a host `systemctl`, so it can't reach +`/srv/docker/maptool` or manage a systemd unit. + +The `deploy` job instead uses `runs-on: host`, a label configured in +the runner's own `config.yaml` that makes the runner (`gitea-runner`, +the binary inside the `gitea/runner:3` image) execute the job's +steps directly inside the runner's *own* container, without spawning a +new one. That container already has `/var/run/docker.sock` mounted, so +`docker`/`docker compose` commands run there act directly on the +host's Docker daemon — this is the same runner used for `check`, just +with a second execution mode, not a separate runner. + +For this to work, the runner's own `docker-compose.yaml` (the one that +starts the runner itself, not this app) needs one more bind mount so +`/srv/docker` inside the runner container is the *same* path as on the +real host: + +[source,yaml] +---- +services: + runner: + image: docker.io/gitea/runner:3 + # ... + volumes: + - ./config.yaml:/config.yaml + - ./data:/data + - /var/run/docker.sock:/var/run/docker.sock + - /srv/docker:/srv/docker +---- + +And the runner's `config.yaml` needs the `host` label added alongside +whatever docker-mode labels it already has. If `config.yaml` is empty +(no prior customization), generate a real default first rather than +hand-writing one — schemas vary across runner versions: + +[source,shell] +---- +docker exec gitea-runner config generate > config.yaml +---- + +(`config generate` — not `generate-config`, which is deprecated; and +the binary is `gitea-runner`, not `act_runner`.) + +Then add the label to the generated `runner: labels:` list — the +generated defaults are commented out, and this list fully *replaces* +the runner's built-in defaults rather than adding to them, so all of +them need to be uncommented too, not just the new one: + +[source,yaml] +---- +runner: + labels: + - "host:host" + - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" + - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" + - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" +---- + +The `1:1` path match matters: the deploy job's working directory +(`/srv/docker/maptool`) needs to resolve to the *same* path whether +read from inside the runner container or by the Docker daemon on the +real host, because `docker-compose.yaml`'s relative bind mount +(`./data`) gets resolved to an absolute path by the `docker compose` +CLI *before* being sent to the daemon — if that path only existed +inside the runner container, the daemon (running on the bare host) +would create/mount the wrong (or a nonexistent) directory. + +After editing both files, recreate the runner container so it picks +up the new volume and label (`docker compose up -d` in the runner's +own directory). + +== Puma's bind address + +`config/puma.rb` binds to `127.0.0.1` by default (see +`docs/architecture.adoc`) — fine for local dev, but inside a container +that would make the app unreachable through Docker's published port +(loopback is scoped to the container's own network namespace). +`docker-compose.yaml` sets `BIND_HOST=0.0.0.0` to override that for +the deployed container specifically; Docker's own port publishing +(`ports:` in the compose file) is the network boundary there instead +of Puma's bind address. + +== CI deploy job + +`.gitea/workflows/ci.yml`'s `deploy` job runs after `check` passes, only +on a push to `main`, with `runs-on: host`: + +. Copies the checkout into `/srv/docker/maptool/` using plain + `cp`/`rm` (no `rsync` in the runner image): clears everything in the + target except `data/`, copies the fresh checkout over, then drops + the copied `.git`. +. `cd /srv/docker/maptool && docker compose -p maptool up --build -d` + — rebuilds the image and recreates the container if anything + changed; a no-op restart if nothing did. + +No `sudo`, no systemd, no SSH — the runner container's existing access +to `/var/run/docker.sock` and (once added) `/srv/docker` is enough.