diff --git a/.github/workflows/build-server-image.yml b/.github/workflows/build-server-image.yml new file mode 100644 index 0000000..6c3587e --- /dev/null +++ b/.github/workflows/build-server-image.yml @@ -0,0 +1,70 @@ +name: Build PTH server image + +# Pull requests: build only (proves the image still builds). +# main and version tags: build and publish to GitHub Container Registry. +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: # adds a "Run workflow" button in the Actions tab + +env: + IMAGE: ghcr.io/${{ github.repository_owner }}/pth-server + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # allows pushing to ghcr.io + steps: + - uses: actions/checkout@v4 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 # builder that supports layer caching + + - name: Log in to GitHub Container Registry + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} # provided by Actions, no secret to manage + + - name: Work out image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=tag + type=sha,format=short + + - name: Build (and push on main/tags) + uses: docker/build-push-action@v6 + with: + context: server + platforms: linux/amd64 + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke test the image + run: | + docker build -t pth-test server + # no volume mount: the image's own /data is owned by the app user. + # (On a real server, chown the mounted folder to 1001:1001 first.) + docker run -d --name pth -p 5000:5000 pth-test + sleep 3 && docker logs pth + for i in $(seq 30); do + curl -fsS http://127.0.0.1:5000/pth/api/ndays?days=1 && break || sleep 1 + done + curl -fsS -X POST -H 'Content-Type: application/json' \ + -d '{"time":1700000000,"device_id":"ci","Test Channel":1.5}' \ + http://127.0.0.1:5000/pth/api/store_data + curl -fsS "http://127.0.0.1:5000/pth/api/closest?time=1700000000&device_id=ci" + docker rm -f pth diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..d18d439 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,7 @@ +# Copy to .env next to compose.yaml on the server and fill in. + +# Folder on the attached volume that holds pth_data.db +PTH_DATA_DIR=/mnt/volume_nyc1_XXXXXXXXXX/pth + +# Image tag to run. "latest" follows main; pin a release tag (e.g. v1.1) for production. +PTH_VERSION=latest diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..7c79cda --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,30 @@ +# PTH server on the lab server. +# +# cp .env.example .env # then set PTH_DATA_DIR (and PTH_VERSION to pin a release) +# docker compose pull && docker compose up -d +# +# No ports are published: the edge proxy (Caddy) reaches this container over +# the shared "lab-net" network at http://pth:5000. +name: pth + +services: + pth: + image: ghcr.io/ddomlab/pth-server:${PTH_VERSION:-latest} + container_name: pth + restart: unless-stopped + environment: + TZ: UTC + PTH_DB_PATH: /data/pth_data.db + volumes: + # SQLite database, on the attached volume so it outlives the container + - ${PTH_DATA_DIR:?set PTH_DATA_DIR in .env}:/data + networks: + - lab-net + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } + +networks: + lab-net: + # created once on the server with: docker network create lab-net + external: true diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..aa47cf3 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.egg-info/ +*.db +*.db-wal +*.db-shm +build/ diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..0456036 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,35 @@ +# PTH server (Flask + SQLite). Build context: this directory. +FROM python:3.12-slim + +# uv installs dependencies exactly as pinned in uv.lock +COPY --from=ghcr.io/astral-sh/uv:0.11.7 /uv /bin/uv + +ENV PYTHONUNBUFFERED=1 \ + TZ=UTC \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + PATH=/app/.venv/bin:$PATH \ + PTH_DB_PATH=/data/pth_data.db + +WORKDIR /app + +# dependencies first: this layer is cached and only rebuilt when the lockfile changes +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +# then the application code +COPY app.py ./ +COPY pth_server ./pth_server +RUN uv sync --frozen --no-dev + +# run as a non-root user; /data is where the SQLite database lives (mounted from the host) +RUN useradd --system --uid 1001 pth && mkdir -p /data && chown pth:pth /data +USER pth +VOLUME ["/data"] +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5000/pth/api/ndays?days=1', timeout=4).status==200 else 1)" + +# 2 workers: enough for one sensor plus dashboard users; access log to stdout for `docker logs` +CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "--access-logfile", "-", "app:app"]