From 76d0f97b71e8e10ad512d23354e90e3f4de8f484 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sun, 11 Oct 2026 13:31:41 -0300 Subject: [PATCH 1/2] chore(monorepo): give the MCP server its own compose project The MCP server was a service in the backend's compose files, so a change to its configuration only reached production with a backend release. Move it to apps/mcp-server/docker-compose.yml with its own .env, joining the backend's Docker network (now with a fixed name) as an external network. The MCP deploy uploads, validates and swaps in its compose file from its own release tag, like the backend deploy does. --- .github/workflows/mcp-docker-publish.yml | 67 ++++++++++++++++++++---- apps/backend/.env.prod.example | 11 ---- apps/backend/docker-compose.prod.yml | 24 --------- apps/backend/docker-compose.yml | 2 + apps/mcp-server/.env.prod.example | 14 +++++ apps/mcp-server/README.md | 2 + apps/mcp-server/docker-compose.yml | 34 ++++++++++++ docs/deployment.md | 24 ++++++--- 8 files changed, 127 insertions(+), 51 deletions(-) create mode 100644 apps/mcp-server/.env.prod.example create mode 100644 apps/mcp-server/docker-compose.yml diff --git a/.github/workflows/mcp-docker-publish.yml b/.github/workflows/mcp-docker-publish.yml index 52cdf2c..d4ee27b 100644 --- a/.github/workflows/mcp-docker-publish.yml +++ b/.github/workflows/mcp-docker-publish.yml @@ -188,6 +188,14 @@ jobs: environment: production steps: + - name: Checkout compose file + uses: actions/checkout@v6 + with: + ref: mcp-server/v${{ needs.validate.outputs.version }} + sparse-checkout: | + apps/mcp-server/docker-compose.yml + sparse-checkout-cone-mode: false + - name: Connect to Tailscale uses: tailscale/github-action@v4 with: @@ -219,21 +227,62 @@ jobs: printf '%s' "$SSH_PRIVATE_KEY" > "$KEY_FILE" chmod 600 "$KEY_FILE" - ssh -i "$KEY_FILE" \ - -p "$SSH_PORT" \ - -o BatchMode=yes \ - -o IdentitiesOnly=yes \ - -o StrictHostKeyChecking=yes \ + SSH_OPTS=( + -o BatchMode=yes + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=yes + ) + + # The MCP server has its own compose project, separate from the + # backend's, so it deploys without depending on a backend release. + MCP_DEPLOY_PATH="$DEPLOY_PATH/mcp-server" + + # The release's compose file replaces the server's copy, so the server + # only keeps .env. It is uploaded next to the current file and only + # takes its place once the remote script validates it. + echo "Uploading compose file from mcp-server/v$MCP_IMAGE_TAG..." + scp -i "$KEY_FILE" -P "$SSH_PORT" "${SSH_OPTS[@]}" \ + apps/mcp-server/docker-compose.yml \ + "$SSH_USERNAME@$TARGET_HOST:$MCP_DEPLOY_PATH/docker-compose.yml.new" + + printf -v REMOTE_MCP_IMAGE_TAG '%q' "$MCP_IMAGE_TAG" + printf -v REMOTE_MCP_DEPLOY_PATH '%q' "$MCP_DEPLOY_PATH" + + ssh -i "$KEY_FILE" -p "$SSH_PORT" "${SSH_OPTS[@]}" \ "$SSH_USERNAME@$TARGET_HOST" \ - "MCP_IMAGE_TAG='$MCP_IMAGE_TAG' DEPLOY_PATH='$DEPLOY_PATH' bash -s" <<'REMOTE' + "MCP_IMAGE_TAG=$REMOTE_MCP_IMAGE_TAG MCP_DEPLOY_PATH=$REMOTE_MCP_DEPLOY_PATH bash -s" <<'REMOTE' set -euo pipefail - COMPOSE_FILES=(-f docker-compose.yml -f docker-compose.prod.yml) - cd "$DEPLOY_PATH" + COMPOSE_FILES=(-f docker-compose.yml) + cd "$MCP_DEPLOY_PATH" + + for file in docker-compose.yml.new .env; do + if [ ! -f "$file" ]; then + echo "::error::Required deploy file is missing: $MCP_DEPLOY_PATH/$file" + exit 1 + fi + done + + # Compose only warns about variables missing from .env and then uses + # an empty value, so treat that warning as a failure too. + echo "Validating the release's compose file against .env..." + if ! CHECK_OUTPUT="$(docker compose -f docker-compose.yml.new config -q 2>&1)" \ + || grep -q 'variable is not set' <<<"$CHECK_OUTPUT"; then + printf '%s\n' "$CHECK_OUTPUT" + rm -f docker-compose.yml.new + echo "::error::The release's compose file does not work with the server's .env. The current compose file was left in place." + exit 1 + fi + + if [ -f docker-compose.yml ]; then + cp -p docker-compose.yml docker-compose.yml.prev + fi + mv docker-compose.yml.new docker-compose.yml + echo "Compose file updated from the release (previous version kept as docker-compose.yml.prev)" echo "Pulling MCP server image tag: $MCP_IMAGE_TAG" docker compose "${COMPOSE_FILES[@]}" pull bitfinance-mcp-server - docker compose "${COMPOSE_FILES[@]}" up -d --no-deps --no-build --wait --wait-timeout 120 bitfinance-mcp-server + docker compose "${COMPOSE_FILES[@]}" up -d --no-build --wait --wait-timeout 120 bitfinance-mcp-server EXPECTED_IMAGE="gustmrg/bitfinance-mcp-server:$MCP_IMAGE_TAG" RUNNING_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$(docker compose "${COMPOSE_FILES[@]}" ps -q bitfinance-mcp-server)")" diff --git a/apps/backend/.env.prod.example b/apps/backend/.env.prod.example index 5efa82e..49227bd 100644 --- a/apps/backend/.env.prod.example +++ b/apps/backend/.env.prod.example @@ -1,6 +1,5 @@ # Image Tag (e.g., 1.7.0, latest) IMAGE_TAG=latest -MCP_IMAGE_TAG=latest # Database Configuration (REQUIRED - no defaults in production) DB_USER= @@ -44,13 +43,3 @@ RESEND_API_TOKEN= RESEND_WEBHOOK_SECRET= NOTIFICATIONS_FROM_ADDRESS= NOTIFICATIONS_FRONTEND_BASE_URL= - -# MCP Server Configuration -MCP_API_BASE_URL=http://bitfinance-api:8080 -MCP_AGENT_EMAIL= -MCP_AGENT_PASSWORD= -MCP_DEFAULT_ORGANIZATION_ID= -MCP_API_VERSION=1 -MCP_BIND_ADDRESS= -MCP_PORT=8090 -MCP_BEARER_TOKEN= diff --git a/apps/backend/docker-compose.prod.yml b/apps/backend/docker-compose.prod.yml index 81d3afe..f9f8ce5 100644 --- a/apps/backend/docker-compose.prod.yml +++ b/apps/backend/docker-compose.prod.yml @@ -37,30 +37,6 @@ services: max-size: "10m" max-file: "3" - bitfinance-mcp-server: - image: gustmrg/bitfinance-mcp-server:${MCP_IMAGE_TAG:-latest} - container_name: bitfinance-mcp-server - restart: always - ports: - - "${MCP_BIND_ADDRESS}:${MCP_PORT:-8090}:8090" - environment: - - ASPNETCORE_URLS=http://+:8090 - - BITFINANCE_API_BASE_URL=${MCP_API_BASE_URL:-http://bitfinance-api:8080} - - BITFINANCE_AGENT_EMAIL=${MCP_AGENT_EMAIL} - - BITFINANCE_AGENT_PASSWORD=${MCP_AGENT_PASSWORD} - - BITFINANCE_DEFAULT_ORGANIZATION_ID=${MCP_DEFAULT_ORGANIZATION_ID:-} - - BITFINANCE_API_VERSION=${MCP_API_VERSION:-1} - - BITFINANCE_MCP_BEARER_TOKEN=${MCP_BEARER_TOKEN} - depends_on: - - bitfinance-api - networks: - - bitfinance-network - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - bitfinance-db: restart: always ports: diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index 2862575..c0f5e00 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -74,6 +74,8 @@ services: networks: bitfinance-network: + # Fixed name: the MCP server stack (apps/mcp-server) joins it as an external network. + name: bitfinance_bitfinance-network driver: bridge volumes: diff --git a/apps/mcp-server/.env.prod.example b/apps/mcp-server/.env.prod.example new file mode 100644 index 0000000..66c1dc4 --- /dev/null +++ b/apps/mcp-server/.env.prod.example @@ -0,0 +1,14 @@ +# Image Tag (e.g., 0.5.1, latest) +MCP_IMAGE_TAG=latest + +# BitFinance API (reached over the backend's Docker network) +MCP_API_BASE_URL=http://bitfinance-api:8080 +MCP_API_VERSION=1 +MCP_AGENT_EMAIL= +MCP_AGENT_PASSWORD= +MCP_DEFAULT_ORGANIZATION_ID= + +# MCP endpoint +MCP_BIND_ADDRESS= +MCP_PORT=8090 +MCP_BEARER_TOKEN= diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 2b56954..cbfb4c0 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -86,6 +86,8 @@ Example generic remote MCP configuration: For production, bind the Docker port to the VPS Tailscale address and firewall port `8090` so only the Raspberry Pi tailnet IP can reach it. Do not publish the BitFinance API publicly for Hermes; keep `BITFINANCE_API_BASE_URL=http://bitfinance-api:8080` inside the MCP container. +`docker-compose.yml` in this directory runs the server in production as its own compose project, separate from the backend's. It joins the backend's Docker network (`bitfinance_bitfinance-network`) to reach the API, so start the backend stack first. Copy `.env.prod.example` to `.env` next to it and fill in the values, then run `docker compose up --no-build -d`. See `docs/deployment.md` for how releases deploy it. + ## Tools - `bitfinance_list_organizations` diff --git a/apps/mcp-server/docker-compose.yml b/apps/mcp-server/docker-compose.yml new file mode 100644 index 0000000..d0a10e3 --- /dev/null +++ b/apps/mcp-server/docker-compose.yml @@ -0,0 +1,34 @@ +# Production deployment of the MCP server, separate from the backend stack so +# each app deploys on its own. It joins the backend's Docker network to reach +# the API, so the backend stack must be running: +# docker compose up --no-build -d +name: bitfinance-mcp + +services: + bitfinance-mcp-server: + image: gustmrg/bitfinance-mcp-server:${MCP_IMAGE_TAG:-latest} + container_name: bitfinance-mcp-server + restart: always + ports: + - "${MCP_BIND_ADDRESS}:${MCP_PORT:-8090}:8090" + environment: + - ASPNETCORE_URLS=http://+:8090 + - BITFINANCE_API_BASE_URL=${MCP_API_BASE_URL:-http://bitfinance-api:8080} + - BITFINANCE_AGENT_EMAIL=${MCP_AGENT_EMAIL} + - BITFINANCE_AGENT_PASSWORD=${MCP_AGENT_PASSWORD} + - BITFINANCE_DEFAULT_ORGANIZATION_ID=${MCP_DEFAULT_ORGANIZATION_ID:-} + - BITFINANCE_API_VERSION=${MCP_API_VERSION:-1} + - BITFINANCE_MCP_BEARER_TOKEN=${MCP_BEARER_TOKEN} + networks: + - bitfinance-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + # Created by the backend stack (apps/backend/docker-compose.yml). + bitfinance-network: + external: true + name: bitfinance_bitfinance-network diff --git a/docs/deployment.md b/docs/deployment.md index c263648..89699f3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -86,14 +86,24 @@ The deploy prints the service status and recent logs, including when it fails. ### MCP server 1. Builds `gustmrg/bitfinance-mcp-server` for `linux/amd64` and `linux/arm64` and pushes it to Docker Hub as `` and `latest`. -2. On the server, in `DEPLOY_PATH`, with `MCP_IMAGE_TAG=`: +2. Uploads `apps/mcp-server/docker-compose.yml` from the release tag to `DEPLOY_PATH/mcp-server`, checks it with `docker compose config` against that directory's `.env`, and only then puts it in place of the current file, which is kept as `docker-compose.yml.prev`. The deploy stops before touching anything if the check fails or reports a variable that is not set. +3. On the server, in `DEPLOY_PATH/mcp-server`, with `MCP_IMAGE_TAG=`: - pulls the new `bitfinance-mcp-server` image; - - recreates the `bitfinance-mcp-server` container from it. `--no-deps` leaves the API, database and cache untouched. - - records the version as `MCP_IMAGE_TAG` in the server's `.env`. + - recreates the `bitfinance-mcp-server` container from it; + - records the version as `MCP_IMAGE_TAG` in that directory's `.env`. -The backend and the MCP server share the compose project in `apps/backend` (`docker-compose.prod.yml` defines both services) and the same `DEPLOY_PATH`. +### Backend and MCP server compose projects -Only the backend deploy updates the compose files on the server. The MCP server deploy uses the files already there, so republishing an older MCP release cannot bring back older compose files. **Changes to either compose file reach production with the next backend release**, including changes that only affect the MCP server service; release the backend before an MCP release that depends on them. The backend deploy only recreates `bitfinance-api`; changes to other services apply when those containers are next recreated. +The backend and the MCP server are separate compose projects, so each one deploys, and rolls back, without the other: + +| App | Compose files | Directory on the server | Project | +|---|---|---|---| +| backend | `apps/backend/docker-compose.yml`, `docker-compose.prod.yml` | `DEPLOY_PATH` | `bitfinance` | +| mcp-server | `apps/mcp-server/docker-compose.yml` | `DEPLOY_PATH/mcp-server` | `bitfinance-mcp` | + +Each deploy only replaces its own compose files, taken from its own release tag, and each directory has its own `.env`. A change to a compose file reaches production with the next release of that app. + +The MCP server reaches the API at `http://bitfinance-api:8080` over the backend's Docker network, `bitfinance_bitfinance-network`, which it joins as an external network. The backend stack must therefore be running, and the network keeps that fixed name. The backend deploy only recreates `bitfinance-api`; changes to the database or cache services apply when those containers are next recreated. ### Frontend @@ -125,11 +135,11 @@ GitHub secrets used by the publish workflows: | `SSH_HOST` | All | Fallback server address | | `SSH_USERNAME`, `SSH_KEY` | All | SSH login | | `SSH_PORT` | All | Optional, defaults to `22` | -| `DEPLOY_PATH` | Backend, MCP server | Directory with the compose files and `.env` on the server | +| `DEPLOY_PATH` | Backend, MCP server | Directory with the backend's compose files and `.env` on the server; the MCP server uses its `mcp-server` subdirectory | Server-side requirements: -- `DEPLOY_PATH` contains `.env`, created from the `apps/backend/.env.prod.example` template. The backend deploy writes the compose files; `.env` is the only file kept by hand. Add any variable a release needs to `.env` before releasing it, or the deploy stops at the compose check. +- `DEPLOY_PATH` contains `.env`, created from `apps/backend/.env.prod.example`, and `DEPLOY_PATH/mcp-server` contains `.env`, created from `apps/mcp-server/.env.prod.example`. The deploys write the compose files; the `.env` files are the only files kept by hand. Add any variable a release needs to its app's `.env` before releasing it, or the deploy stops at the compose check. - The server's `.env` sets the S3 storage variables (`S3_BUCKET_NAME`, `S3_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`). See "File storage" in `apps/backend/README.md`. - The external Postgres volume (`POSTGRES_DATA_VOLUME`, default `bitfinance_postgres-data`) exists. See `apps/backend/README.md`. - The SSH user can run `docker compose` and can write to `/var/www/bitfinance` without `sudo`. From d078d53cc48098cb2136e946c04d1dea9822a134 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sun, 11 Oct 2026 13:35:42 -0300 Subject: [PATCH 2/2] chore(monorepo): drop explanatory comments from compose and CI files --- .github/workflows/backend-docker-publish.yml | 5 ----- .github/workflows/mcp-docker-publish.yml | 7 ------- apps/backend/.env.prod.example | 2 -- apps/backend/docker-compose.yml | 1 - apps/mcp-server/.env.prod.example | 2 +- apps/mcp-server/docker-compose.yml | 5 ----- 6 files changed, 1 insertion(+), 21 deletions(-) diff --git a/.github/workflows/backend-docker-publish.yml b/.github/workflows/backend-docker-publish.yml index aa8b9ec..79d6d92 100644 --- a/.github/workflows/backend-docker-publish.yml +++ b/.github/workflows/backend-docker-publish.yml @@ -242,9 +242,6 @@ jobs: ssh "${SSH_OPTS[@]}" "$SSH_USERNAME@$TARGET_HOST" \ 'echo "SSH auth OK"; echo "Remote host: $(hostname)"' - # The release's compose files replace the server's copies, so the - # server only keeps .env. They are uploaded next to the current files - # and only take their place once the remote script validates them. echo "Uploading compose files from backend/v$IMAGE_TAG..." for file in docker-compose.yml docker-compose.prod.yml; do scp -i "$KEY_FILE" -P "$SSH_PORT" \ @@ -340,8 +337,6 @@ jobs: fi echo "Backend is running $RUNNING_IMAGE" - # Record the deployed version in .env so compose commands run by hand - # on the server keep this image instead of an older tag. if grep -q '^IMAGE_TAG=' .env; then sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=$IMAGE_TAG/" .env else diff --git a/.github/workflows/mcp-docker-publish.yml b/.github/workflows/mcp-docker-publish.yml index d4ee27b..95f7816 100644 --- a/.github/workflows/mcp-docker-publish.yml +++ b/.github/workflows/mcp-docker-publish.yml @@ -233,13 +233,8 @@ jobs: -o StrictHostKeyChecking=yes ) - # The MCP server has its own compose project, separate from the - # backend's, so it deploys without depending on a backend release. MCP_DEPLOY_PATH="$DEPLOY_PATH/mcp-server" - # The release's compose file replaces the server's copy, so the server - # only keeps .env. It is uploaded next to the current file and only - # takes its place once the remote script validates it. echo "Uploading compose file from mcp-server/v$MCP_IMAGE_TAG..." scp -i "$KEY_FILE" -P "$SSH_PORT" "${SSH_OPTS[@]}" \ apps/mcp-server/docker-compose.yml \ @@ -292,8 +287,6 @@ jobs: fi echo "MCP server is running $RUNNING_IMAGE" - # Record the deployed version in .env so compose commands run by hand - # on the server keep this image instead of an older tag. if grep -q '^MCP_IMAGE_TAG=' .env; then sed -i "s/^MCP_IMAGE_TAG=.*/MCP_IMAGE_TAG=$MCP_IMAGE_TAG/" .env else diff --git a/apps/backend/.env.prod.example b/apps/backend/.env.prod.example index 49227bd..6673b4e 100644 --- a/apps/backend/.env.prod.example +++ b/apps/backend/.env.prod.example @@ -24,8 +24,6 @@ AZURE_CLIENT_SECRET= AZURE_TENANT_ID= # File storage (Amazon S3, REQUIRED in production) -# Credentials of the IAM user that can read, write and delete objects in the bucket. -# Leave Storage__ServiceUrl unset: it is only for S3-compatible servers such as local MinIO. S3_BUCKET_NAME= S3_REGION=us-east-1 AWS_ACCESS_KEY_ID= diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index c0f5e00..e8d0d91 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -74,7 +74,6 @@ services: networks: bitfinance-network: - # Fixed name: the MCP server stack (apps/mcp-server) joins it as an external network. name: bitfinance_bitfinance-network driver: bridge diff --git a/apps/mcp-server/.env.prod.example b/apps/mcp-server/.env.prod.example index 66c1dc4..15654c3 100644 --- a/apps/mcp-server/.env.prod.example +++ b/apps/mcp-server/.env.prod.example @@ -1,7 +1,7 @@ # Image Tag (e.g., 0.5.1, latest) MCP_IMAGE_TAG=latest -# BitFinance API (reached over the backend's Docker network) +# BitFinance API MCP_API_BASE_URL=http://bitfinance-api:8080 MCP_API_VERSION=1 MCP_AGENT_EMAIL= diff --git a/apps/mcp-server/docker-compose.yml b/apps/mcp-server/docker-compose.yml index d0a10e3..9ea8c7b 100644 --- a/apps/mcp-server/docker-compose.yml +++ b/apps/mcp-server/docker-compose.yml @@ -1,7 +1,3 @@ -# Production deployment of the MCP server, separate from the backend stack so -# each app deploys on its own. It joins the backend's Docker network to reach -# the API, so the backend stack must be running: -# docker compose up --no-build -d name: bitfinance-mcp services: @@ -28,7 +24,6 @@ services: max-file: "3" networks: - # Created by the backend stack (apps/backend/docker-compose.yml). bitfinance-network: external: true name: bitfinance_bitfinance-network