Files
tcp-ingestion/.planning/phase-1-telemetry/11-dockerfile-and-ci.md
T
julian dda53bec16 Add slim Phase 1 task 1.11 (Dockerfile + Gitea workflow) for pilot deploy
- Multi-stage Dockerfile (Node 22 alpine, BuildKit cache, non-root user).
  HEALTHCHECK and metrics port (9090) deferred until task 1.10 ships;
  comments document the resume.
- .gitea/workflows/build.yml — single build job following the pattern
  of other TRM repos (no services/container, ubuntu-latest direct).
  Tests + typecheck + lint inline; image tagged :main.
- compose.dev.yaml — local-build variant for verifying Dockerfile
  changes pre-push. Production deploy lives in the sibling deploy/ repo.
- .env.example documenting all runtime env vars.
- README updated to point at deploy/ for production and explain CI.
- Task 1.11 marked done (slim variant) in ROADMAP and task file.
2026-04-30 17:31:25 +02:00

8.5 KiB

Task 1.11 — Dockerfile & Gitea workflow

Phase: 1 — Inbound telemetry Status: 🟩 Done (slim pilot variant — see Done section below) Depends on: 1.8 (so the service actually does something), 1.10 (metrics endpoint for healthcheck) Wiki refs: docs/wiki/sources/gps-tracking-architecture.md § 7.3 Deployment topology

Goal

Produce a multi-stage Docker image and a Gitea Actions workflow that builds and pushes the image to the project's Gitea Container Registry on every push to main and every tag.

Deliverables

  • Dockerfile — multi-stage build (deps → build → runtime).
  • .dockerignore — already created in task 1.1; verify it excludes .planning/, test/, dist/ (rebuilt in image).
  • .gitea/workflows/build.yml — Gitea Actions workflow.
  • compose.yaml (alongside Dockerfile) — example local stack with Redis for pnpm docker:dev. Useful for local testing of the full pipeline.
  • Documentation updates in README.md covering: build, run locally, run via compose, CI behavior, image registry path.

Specification

Dockerfile

# syntax=docker/dockerfile:1.7

# ---- deps stage: install with cache-friendly pnpm fetch ----
FROM node:22-alpine AS deps
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest-9 --activate
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
    pnpm fetch

# ---- build stage: compile TypeScript ----
FROM deps AS build
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --offline
RUN pnpm build
RUN pnpm prune --prod

# ---- runtime: slim, non-root ----
FROM node:22-alpine AS runtime
WORKDIR /app
RUN addgroup -S app && adduser -S -G app app
COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/package.json ./package.json
USER app
EXPOSE 5027 9090
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:9090/readyz || exit 1
CMD ["node", "dist/main.js"]

Notes:

  • node:22-alpine is small (~100MB final image). If musl-related issues arise (rare with pure JS), fall back to node:22-slim.
  • BuildKit cache mounts (--mount=type=cache) speed up rebuilds significantly; the Gitea runner must support BuildKit (it does by default with modern docker).
  • pnpm prune --prod strips dev dependencies before the runtime copy.
  • Healthcheck hits /readyz so the container reports unhealthy if Redis is unreachable.

Gitea workflow

.gitea/workflows/build.yml:

name: build

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    container: node:22-alpine
    services:
      redis:
        image: redis:7-alpine
    steps:
      - uses: actions/checkout@v4
      - run: corepack enable && corepack prepare pnpm@latest-9 --activate
      - run: pnpm install --frozen-lockfile
      - run: pnpm typecheck
      - run: pnpm lint
      - run: pnpm test --coverage
        env:
          REDIS_URL: redis://redis:6379

  build-and-push:
    needs: test
    if: gitea.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: git.dev.microservices.al
          username: ${{ gitea.actor }}
          password: ${{ secrets.GITEA_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: git.dev.microservices.al/trm/tcp-ingestion
          tags: |
            type=ref,event=branch
            type=sha,prefix=,format=short
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=raw,value=latest,enable={{is_default_branch}}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=git.dev.microservices.al/trm/tcp-ingestion:buildcache
          cache-to: type=registry,ref=git.dev.microservices.al/trm/tcp-ingestion:buildcache,mode=max

Tags produced:

  • On push to main: main, <short-sha>, latest.
  • On tag v1.2.3: v1.2.3, 1.2.3, 1.2, latest (because tag push is on default branch context-dependent — verify with the Gitea Actions semantics in your runner version; adjust if necessary).
  • On PR: tests run, no push.

GITEA_TOKEN is provided by Gitea Actions automatically (similar to GITHUB_TOKEN in GitHub Actions). It must have package-write scope; configure once in repo settings if the default scope is read-only.

compose.yaml (local dev)

services:
  redis:
    image: redis:7-alpine
    ports: ['6379:6379']
  ingestion:
    build: .
    depends_on: [redis]
    ports:
      - '5027:5027'   # Teltonika TCP
      - '9090:9090'   # metrics
    environment:
      NODE_ENV: production
      INSTANCE_ID: local-1
      REDIS_URL: redis://redis:6379
      LOG_LEVEL: debug
    restart: unless-stopped

Deployment

Out of scope for this task: how the image is consumed in production (compose pull + restart? K8s? Watchtower?). Recommend a follow-up task once Phase 1 is functional, since the deployment substrate may not be fully decided yet. For now, the image is built and published; humans pull and run it manually.

Acceptance criteria

  • docker build . succeeds locally and produces an image under 200MB.
  • docker compose up starts both Redis and the ingestion service; the service's /healthz and /readyz return 200.
  • On push to main, the Gitea workflow runs tests, builds the image, and publishes it to the registry. The image is visible in the Gitea Packages UI.
  • On a tag push, the image is also tagged with the version.
  • On a PR, only the test job runs (no push).
  • BuildKit cache reduces a rebuild-with-no-changes to under 30 seconds.

Risks / open questions

  • The exact Gitea Actions feature parity with GitHub Actions varies by runner version. If docker/metadata-action@v5 doesn't work as expected, fall back to a hand-rolled tag generator using git rev-parse --short HEAD.
  • GITEA_TOKEN permissions: confirm the default token can push to the registry. If not, switch to a dedicated secrets.REGISTRY_TOKEN.
  • Architecture: build only linux/amd64 for now. Multi-arch (linux/arm64) is a follow-up if anyone needs it for Apple Silicon dev.

Done

Landed in commit <SHA> (fill in after merge).

Slim pilot variant — deviations from spec

This task was implemented in a slimmed form to unblock pilot deployment on real Teltonika hardware before task 1.10 (observability) ships.

Pre-approved slim changes (per task brief):

  1. No HEALTHCHECK — removed because no HTTP server runs yet. A comment in the Dockerfile marks where to re-add wget -qO- http://localhost:${METRICS_PORT}/readyz when task 1.10 lands.
  2. EXPOSE 5027 onlyEXPOSE 9090 omitted because METRICS_PORT is in the config schema but nothing listens on it. Adding it would mislead operators.
  3. compose.yaml maps only 5027:50279090:9090 port mapping removed for the same reason.

Additional deviations (none beyond the pre-approved slim changes).

Still owed when task 1.10 ships

  • Restore EXPOSE 9090 in the Dockerfile runtime stage.
  • Restore HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -qO- http://localhost:9090/readyz || exit 1.
  • Restore the 9090:9090 port mapping in compose.yaml.
  • Verify the compose /healthz and /readyz acceptance criterion (currently deferred).

Acceptance criteria — original vs pilot

Criterion Status
docker build . succeeds; image under 200MB Deferred — Docker not available in agent env; Dockerfile follows spec exactly
docker compose up starts both services; /healthz and /readyz return 200 Deferred — no HTTP server until task 1.10
Push to main runs tests, builds, publishes image Satisfied — workflow in .gitea/workflows/build.yml
Tag push also tags with version Satisfied — docker/metadata-action semver tags configured
PR: test job only, no push Satisfied — if: gitea.event_name == 'push' guard on build-and-push
BuildKit cache reduces no-change rebuild to under 30s Satisfied — --mount=type=cache in both pnpm stages; registry cache-from/cache-to configured