Skip to content

Deploy Cloud

Deploy Cloud #198

Workflow file for this run

name: Deploy Cloud
on:
workflow_dispatch:
inputs:
tag:
description: "Docker image tag to deploy (default: latest)"
required: false
default: "latest"
type: string
# One deploy at a time. On 2026-09-25 two runs of the same commit started two
# minutes apart; the second failed in `docker pull` ("unable to lease
# content") while the first was swapping containers. Worse cases are easy to
# picture: a second run recording the first's half-started release as its
# rollback point, or sharing /tmp/anythingmcp-deploy with it. Queue instead.
concurrency:
group: deploy-cloud
cancel-in-progress: false
jobs:
deploy:
name: Deploy to Cloud Droplet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Copy config files to server
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ secrets.CLOUD_HOST }}
username: root
key: ${{ secrets.CLOUD_SSH_KEY }}
source: "docker-compose.cloud.yml,deploy/cloud/Caddyfile,deploy/cloud/release.sh,deploy/cloud/uptime-probe.sh,deploy/cloud/systemd/anythingmcp-probe.service,deploy/cloud/systemd/anythingmcp-probe.timer,deploy/cloud/stuck-users-report.sh,deploy/cloud/systemd/anythingmcp-stuck-report.service,deploy/cloud/systemd/anythingmcp-stuck-report.timer,deploy/cloud/trial-report.sh,deploy/cloud/systemd/anythingmcp-trial-report.service,deploy/cloud/systemd/anythingmcp-trial-report.timer,deploy/motis/Dockerfile,deploy/motis/config.yml,deploy/motis/entrypoint.sh"
target: /tmp/anythingmcp-deploy
overwrite: true
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
env:
CRON_SECRET: ${{ secrets.ONBOARDING_CRON_SECRET }}
LICENSE_SERVICE_TOKEN: ${{ secrets.LICENSE_SERVICE_TOKEN }}
with:
host: ${{ secrets.CLOUD_HOST }}
username: root
key: ${{ secrets.CLOUD_SSH_KEY }}
envs: CRON_SECRET,LICENSE_SERVICE_TOKEN
# The action's default is 10m. A release can take longer (up to
# 5 min waiting for the new containers, the site checks, up to
# 2 min draining the old ones), and a timeout mid-release would
# leave it half done — the old colour still serving, but the job
# unable to say so.
command_timeout: 30m
script: |
set -e
cd /opt/anythingmcp-cloud
# Pull the requested tag AND refresh :latest, since
# docker-compose.cloud.yml references :latest. Without this
# the local :latest tag can point to an older image.
docker pull helpcodeai/anythingmcp:${{ inputs.tag }}
docker pull helpcodeai/anythingmcp:latest
# docker-compose.cloud.yml and the Caddyfile are NOT copied here:
# release.sh installs them, after saving the ones in use, so a
# failed release can put them back. The MOTIS build context is
# copied below; `docker compose up` builds it if the pinned image
# tag is missing (fresh droplet or a bumped tag), otherwise the
# existing image is reused.
# Uptime probe: a systemd timer on this host, every minute, mailing
# on a change of state. It is here and not only in GitHub Actions
# because the scheduled workflow ran three times in its first ten
# hours — GitHub's cron is best-effort — and the failure mode we
# have is the backend dying while the host stays up, which a timer
# on the host sees within a minute. Reads DOMAIN, SMTP_* and
# UPTIME_ALERT_TO from this directory's .env; with no
# UPTIME_ALERT_TO it only logs to the journal.
install -m 755 /tmp/anythingmcp-deploy/deploy/cloud/uptime-probe.sh ./uptime-probe.sh
install -m 644 /tmp/anythingmcp-deploy/deploy/cloud/systemd/anythingmcp-probe.service /etc/systemd/system/
install -m 644 /tmp/anythingmcp-deploy/deploy/cloud/systemd/anythingmcp-probe.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now anythingmcp-probe.timer
systemctl is-active anythingmcp-probe.timer
# Weekly stuck-users report, Monday 07:00 Europe/Berlin: orgs whose
# tool calls have never succeeded, connectors failing for everyone,
# signups that never got going. Read-only on the database. Mails
# REPORT_TO (else UPTIME_ALERT_TO) through this directory's SMTP_*;
# with neither set it writes to the journal. The timer does not fire
# on install, only on the next Monday.
install -m 755 /tmp/anythingmcp-deploy/deploy/cloud/stuck-users-report.sh ./stuck-users-report.sh
install -m 644 /tmp/anythingmcp-deploy/deploy/cloud/systemd/anythingmcp-stuck-report.service /etc/systemd/system/
install -m 644 /tmp/anythingmcp-deploy/deploy/cloud/systemd/anythingmcp-stuck-report.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now anythingmcp-stuck-report.timer
systemctl is-active anythingmcp-stuck-report.timer
# Daily trial report, 08:05 Europe/Berlin: licences that became paid,
# trials ending in the next 3 days and how far each workspace got,
# trials that ended yesterday. Read-only, same mail route as above.
install -m 755 /tmp/anythingmcp-deploy/deploy/cloud/trial-report.sh ./trial-report.sh
install -m 644 /tmp/anythingmcp-deploy/deploy/cloud/systemd/anythingmcp-trial-report.service /etc/systemd/system/
install -m 644 /tmp/anythingmcp-deploy/deploy/cloud/systemd/anythingmcp-trial-report.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now anythingmcp-trial-report.timer
systemctl is-active anythingmcp-trial-report.timer
mkdir -p ./deploy/motis
cp /tmp/anythingmcp-deploy/deploy/motis/* ./deploy/motis/
rm -rf ./deploy/cloud/db-rest
# Keep CRON_SECRET in the server .env in sync with the repo
# secret so the onboarding-reminders cron can authenticate.
# Upsert (replace-or-append) without disturbing other vars.
touch .env
if grep -q '^CRON_SECRET=' .env; then
sed -i "s#^CRON_SECRET=.*#CRON_SECRET=${CRON_SECRET}#" .env
else
echo "CRON_SECRET=${CRON_SECRET}" >> .env
fi
# Same for the licence service token, which anythingmcp.com checks
# to tell this backend apart from the public when it asks for a
# trial. Must match LICENSE_SERVICE_TOKEN on the website droplet.
if [ -n "${LICENSE_SERVICE_TOKEN}" ]; then
if grep -q '^LICENSE_SERVICE_TOKEN=' .env; then
sed -i "s#^LICENSE_SERVICE_TOKEN=.*#LICENSE_SERVICE_TOKEN=${LICENSE_SERVICE_TOKEN}#" .env
else
echo "LICENSE_SERVICE_TOKEN=${LICENSE_SERVICE_TOKEN}" >> .env
fi
fi
trap 'rm -rf /tmp/anythingmcp-deploy' EXIT
# Blue/green: start the new release next to the one serving, check
# it privately and then publicly, switch Caddy, stop the old one —
# see deploy/cloud/release.sh. A release that fails its checks is
# removed before it serves anything and this job goes red with the
# previous release still serving. The uptime probe is no longer
# silenced for the deploy: a healthy one has no downtime to hide.
# release.sh silences it itself only when it has to fall back to
# stop-then-start (no room on the host for two backends).
bash /tmp/anythingmcp-deploy/deploy/cloud/release.sh
docker image prune -f
# Keep the eight most recent app images and drop the rest. On
# 2026-09-19 the droplet held 35 of them going back four months,
# ~1.3 GB each — the bulk of the 34 GB under /var/lib/containerd,
# growing by one image per deploy.
#
# Safe because these are a local cache, not an archive: every tag
# is still on Docker Hub, so an older rollback target comes back
# with `docker pull helpcodeai/anythingmcp:<tag>`. Eight covers
# roughly six weeks of builds. An image a container is using
# cannot be removed — docker refuses, and the `|| true` keeps the
# deploy green when it does.
docker images helpcodeai/anythingmcp \
--format '{{.CreatedAt}}\t{{.Repository}}:{{.Tag}}' \
| sort -r | tail -n +9 | cut -f2 \
| while read -r img; do
[ "$img" = "helpcodeai/anythingmcp:latest" ] && continue
echo "pruning cached image $img"
docker rmi "$img" >/dev/null 2>&1 || true
done
echo "Deployment complete"