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.
This commit is contained in:
1 parent
f186a3e8ef
commit
3a79c3d6c1
6 files changed
+203
-1
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
data
|
||||||
|
.gitea
|
||||||
|
.claude
|
||||||
|
.ruby-lsp
|
||||||
@@ -29,3 +29,39 @@ jobs:
|
|||||||
|
|
||||||
- name: JS syntax check
|
- name: JS syntax check
|
||||||
run: find public/js -name "*.js" -print0 | xargs -0 -n1 node --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
|
||||||
+17
@@ -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"]
|
||||||
+6
-1
@@ -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")
|
environment ENV.fetch("RACK_ENV", "development")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 <runner-container> 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.
|
||||||
Reference in new issue
Block a user