feat(deploy): publish image to GHCR and document Synology DSM setup

Add a release workflow that publishes a linux/amd64 image to
ghcr.io/richardnixondev/reddit-media-collector on every v* tag, plus a
ready-to-paste docker-compose for Synology Container Manager.

Persist scheduler state across container restarts by reading the DB and
config paths from RMC_SCHEDULER_DB / RMC_SCHEDULER_CONFIG (previously
written to /app, which is not a volume).

Drive-by fixes uncovered while smoke-testing the image:
- Dockerfile now copies README.md (required by pyproject) and drops the
  single-file VOLUME entry that breaks bind mounts; adds an OCI source
  label and a socket-based HEALTHCHECK so Container Manager reflects real
  liveness.
- src/web/app.py uses the new Starlette TemplateResponse signature so the
  index page does not 500 under fresh dependency pins.
This commit is contained in:
authentik Default Admin 2026-05-17 14:48:02 +01:00
parent b7265f0582
commit d02e071ddf
6 changed files with 135 additions and 9 deletions

43
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/reddit-media-collector
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -8,13 +8,17 @@ WORKDIR /app
FROM base AS builder
COPY pyproject.toml ./
COPY pyproject.toml README.md ./
COPY src/ ./src/
RUN pip install --no-cache-dir .
FROM base AS runtime
LABEL org.opencontainers.image.source="https://github.com/richardnixondev/reddit-media-collector"
LABEL org.opencontainers.image.description="Self-hosted media collector for Reddit with Immich integration"
LABEL org.opencontainers.image.licenses="MIT"
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
@ -30,6 +34,7 @@ ENV RMC_TIMEZONE=UTC
EXPOSE 8000
VOLUME ["/app/downloads", "/app/data", "/app/config.yaml"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import socket,sys; s=socket.socket(); s.settimeout(3); s.connect(('127.0.0.1',8000)); s.close()" || exit 1
CMD ["uvicorn", "src.web.app:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -214,10 +214,58 @@ docker-compose down
### Docker Configuration
The `docker-compose.yml` mounts the following volumes:
- `./config.yaml` - Configuration file (read-only)
- `./downloads` - Downloaded media
- `./media.db` - SQLite database
- `./collector.log` - Log file
- `./config.yaml``/app/config.yaml` (read-only)
- `./downloads``/app/downloads` (media files)
- `./data``/app/data` (SQLite DB, scheduler DB/config — survives upgrades)
Relevant environment variables (all optional, with sensible defaults inside the image):
| Variable | Default | Purpose |
|---|---|---|
| `RMC_DOWNLOAD_DIR` | `/app/downloads` | Where media files are written |
| `RMC_DB_PATH` | `/app/data/media.db` | Main SQLite database |
| `RMC_SCHEDULER_DB` | `/app/scheduler.db` | APScheduler jobstore — **set to `/app/data/scheduler.db` in containers** so history survives restarts |
| `RMC_SCHEDULER_CONFIG` | `/app/scheduler_config.yaml` | Scheduler interval/cron config — same advice as above |
| `RMC_CONFIG_PATH` | `/app/config.yaml` | YAML with subreddits/users/blacklist |
| `RMC_TIMEZONE` | `UTC` | Timezone used by the scheduler |
| `RMC_AUTH_USER` / `RMC_AUTH_PASS` | unset | Enable HTTP Basic Auth on every route when both are set |
## Synology DSM Deployment
Tested on DSM 7.2 with **Container Manager** on x86_64 Plus models. The published image lives at
`ghcr.io/richardnixondev/reddit-media-collector:latest`.
1. **Prepare the host directories** (one-time, via SSH or File Station):
```bash
sudo mkdir -p /volume1/docker/reddit-media-collector/{downloads,data}
sudo cp /path/to/your/config.yaml /volume1/docker/reddit-media-collector/config.yaml
```
2. **Create the project in Container Manager:**
- Open *Container Manager → Project → Create*.
- Project name: `reddit-media-collector`.
- Path: `/volume1/docker/reddit-media-collector`.
- Source: *Create docker-compose.yml*, paste the contents of [`docker-compose.synology.yml`](docker-compose.synology.yml) (adjust `RMC_TIMEZONE` to your zone).
- *Next → Build* — DSM does `docker compose pull && up -d`.
3. **Access the UI:** `http://<nas-ip>:8000`. Downloaded media appears in
`/volume1/docker/reddit-media-collector/downloads/`, which you can point Synology Photos or
an Immich instance at.
4. **HTTPS via DSM Reverse Proxy (optional, recommended if exposed):**
*Control Panel → Login Portal → Advanced → Reverse Proxy → Create*. Source:
`reddit.yourdomain.com` (HTTPS:443). Destination: `localhost:8000` (HTTP). Attach a
Let's Encrypt cert. When exposing publicly, uncomment `RMC_AUTH_USER`/`RMC_AUTH_PASS`
in the compose file.
5. **Updating:** *Container Manager → Project → reddit-media-collector → Action → Build*
re-pulls `latest`. The `data/` volume keeps the database, scheduler history and config.
**Permissions note:** if the container can't write to the bind mounts, check the owner
of `/volume1/docker/reddit-media-collector/` with `ls -ln`. Either `chown` it to a user
the container can write as, or set `user: "UID:GID"` in the compose (commented out at the
bottom of `docker-compose.synology.yml`).
## File Naming Convention

View file

@ -0,0 +1,30 @@
# Compose para deploy no Synology DSM via Container Manager.
# Puxa a imagem pré-construída do GHCR (sem build local no NAS).
# Antes de subir, crie as pastas no NAS:
# mkdir -p /volume1/docker/reddit-media-collector/{downloads,data}
# cp config.yaml /volume1/docker/reddit-media-collector/config.yaml
services:
collector:
image: ghcr.io/richardnixondev/reddit-media-collector:latest
container_name: reddit-media-collector
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- /volume1/docker/reddit-media-collector/downloads:/app/downloads
- /volume1/docker/reddit-media-collector/data:/app/data
- /volume1/docker/reddit-media-collector/config.yaml:/app/config.yaml:ro
environment:
- RMC_TIMEZONE=America/Sao_Paulo
- RMC_DOWNLOAD_DIR=/app/downloads
- RMC_DB_PATH=/app/data/media.db
- RMC_SCHEDULER_DB=/app/data/scheduler.db
- RMC_SCHEDULER_CONFIG=/app/data/scheduler_config.yaml
# Ative HTTP Basic Auth se for expor pela internet (via Reverse Proxy DSM):
# - RMC_AUTH_USER=admin
# - RMC_AUTH_PASS=trocar-isto
# Se o DSM rodar o container como user diferente e der erro de permissão
# nas pastas, descomente abaixo apontando para o UID:GID dono de
# /volume1/docker/reddit-media-collector (descubra com `ls -ln`):
# user: "1026:100"

View file

@ -51,5 +51,5 @@ async def index(request: Request):
users = config_manager.get_users()
blacklist = config_manager.get_blacklist()
return templates.TemplateResponse(
"index.html", {"request": request, "subreddits": subreddits, "users": users, "blacklist": blacklist}
request, "index.html", {"subreddits": subreddits, "users": users, "blacklist": blacklist}
)

View file

@ -18,10 +18,10 @@ THUMBS_DIR.mkdir(parents=True, exist_ok=True)
TIMEZONE = os.environ.get("RMC_TIMEZONE", "UTC")
# Scheduler config file path
SCHEDULER_CONFIG_PATH = PROJECT_DIR / "scheduler_config.yaml"
SCHEDULER_CONFIG_PATH = Path(os.environ.get("RMC_SCHEDULER_CONFIG", str(PROJECT_DIR / "scheduler_config.yaml")))
# Scheduler DB path
SCHEDULER_DB_PATH = PROJECT_DIR / "scheduler.db"
SCHEDULER_DB_PATH = Path(os.environ.get("RMC_SCHEDULER_DB", str(PROJECT_DIR / "scheduler.db")))
# Collector state (protected by lock for thread safety)
collector_lock = threading.Lock()