Independent Flask service for publishing signed Axolotl Launcher release and beta updates.
See the deployment and API documentation below.
Python 3.11+ is required. Create a virtual environment, install dependencies, copy .env.example to .env, and set non-default SECRET_KEY, WEBHOOK_SECRET, and ADMIN_TOKEN values. UPLOAD_TOKEN is optional and only used for local/admin artifact maintenance.
python -m venv .venv
.venv/bin/pip install -r requirements.txt
flask --app wsgi:create_app run --host 127.0.0.1 --port 8082The Windows equivalent is .venv\\Scripts\\python -m pip install -r requirements.txt. SQLite is used by default; DATABASE_URL accepts any SQLAlchemy URL. Development/test runs may create tables automatically; production schema changes must use Flask-Migrate.
Complete endpoint documentation is available in docs/API.md.
To remove a version for republishing, use DELETE /api/admin/versions/<version>
with the admin Bearer token and optional {"operator":"admin","reason":"Republish"}.
It removes local version files, artifact/version records and associated webhook
events, recomputes both channel pointers, and records an audit log. Historical
statistics and GitHub/CNB Releases are retained. Apply migrations through
0008_artifact_target_platforms before startup. See the API reference for cleanup
retry behavior, legacy webhook IDs and immutable CDN cache considerations.
GET /api/health,GET /api/versions, andGET /api/versions/<version>expose service/version data.- Usage collection is disabled: API/update checks and file downloads do not insert statistics or commit a per-request database transaction.
GET /api/admin/statsremains a read-only legacy endpoint for old data; runflask --app wsgi:app purge-usageto delete history and compact SQLite. - Upload with
PUT /api/artifacts/<version>/<filename>andX-Upload-Token. The body is hashed server-side and stored belowDIST_ROOT/<version>/(with the defaultDIST_ROOT=./dist, this is./dist/<version>/). Package metadata is supplied withX-Axolotl-Platform(windows,macos,linuxor an updater platform),X-Axolotl-Architecture(x86_64,aarch64,universal),X-Axolotl-Kind(updater,installer,portable,signature,manifest,other), optionalX-Axolotl-Variant, andX-Axolotl-Display-Name. Existing uploads default tokind=updaterfor compatibility. - Publish with
POST /api/webhook/release. The normal GitHub Actions flow first publishes the GitHub Release, then sends a small HMAC-authenticated payload. The server validates the public GitHub metadata and catalog, streams every catalog file (including.sig) toDIST_ROOT/<version>/, validates SHA-256 and size, then publishes atomically. The workflow must not upload large files through/api/artifacts/*and does not needUPLOAD_TOKEN. - Check updates with
/latest; headersX-Axolotl-Channel,X-Axolotl-Platform, andX-Axolotl-Versionoverride query parameters. Release selects only stable published Release versions. Beta selects the highest compatible stable Release or published beta prerelease. - Browse complete public packages with
GET /api/downloads/latest?channel=release|betaorGET /api/downloads/<version>. These endpoints return installer and portable artifacts with generated/dist/<version>/...URLs, labels, variants, sizes, and SHA-256 hashes. They intentionally exclude updater artifacts unlessinclude_updater=trueis supplied; they never expose signature-only attachments as packages. - Admin endpoints require
Authorization: Bearer <ADMIN_TOKEN>:POST /api/admin/versions/<version>/revoke,POST /api/admin/versions/<version>/restore,PATCH /api/admin/versions/<version>/force-update, andGET /api/admin/audit-logs. The force-update endpoint accepts{"force_update":true,"operator":"admin","reason":"Urgent fix"}(orfalseto disable), synchronizes the local manifest, and records an audit log.
/latest returns a Tauri-compatible manifest with UTC pub_date, published_at, and force_update, server-generated ETags, and only https://update.axlmc.org/dist/... URLs. force_update=true tells Launcher to bypass its normal 24-hour Release delay; it never bypasses pause settings or signature verification. It returns 204 when no compatible update exists. ETag/304 is optional optimization and not required by the Launcher. Flask's development /dist/<version>/<filename> fallback supports GET, HEAD, and Range; revoked files remain downloadable by direct URL, but are never returned by /latest.
Webhook payloads include public GitHub Release metadata and a catalog. release.published_at remains the update publication time. catalog.files lists all physical assets (including signatures), while catalog.artifacts lists only main updater/installer/portable artifacts. Updater artifacts must name their .sig companion with signatureFilename; its trimmed UTF-8 content becomes the Tauri signature. An updater may also provide targetPlatforms; universal macOS artifacts map one file to both darwin-aarch64 and darwin-x86_64, and an unpublished version cannot retain another updater for an overlapping target. Complete packages may have an attachment but do not require a Tauri signature. Payloads may include "force_update": true (default is false):
{"event_id":"github-v1.10.0-123","tag":"v1.10.0","version":"1.10.0","channel":"release","force_update":true,"release":{"id":123,"tag_name":"v1.10.0","draft":false,"published_at":"2026-08-30T10:00:00Z","assets":[]},"catalog":{"version":"1.10.0","files":[],"artifacts":[]}}Webhook retries with the same event ID and payload hash are safe; a failed event may be retried after fixing artifacts. Reusing an event ID with a different payload is rejected.
The automatic updater and the complete package catalog are separate: /latest contains only signed Tauri updater artifacts, while /api/downloads/* is for website/manual downloads. GitHub Release and CNB Release remain publishing records and manual-download sources; Launcher automatic updates use only this Update Server.
The newest three published Release versions and three published Beta versions remain in DIST_ROOT. When a request targets an artifact from a GitHub-imported version pruned by this retention policy, /dist/<version>/<filename> returns 302 Found to its fixed GitHub Release asset URL. Existing local files continue to be served directly by Caddy; missing unknown, current, or manually uploaded artifacts return 404.
Revoking a version removes it from /latest and download-directory latest selection while retaining its immutable /dist/<version>/... URLs for in-progress or previously published links. A separate blocked artifact state is intentionally not enabled yet; urgent takedowns should be handled at the Caddy/object-storage layer until that policy is introduced with an audited migration.
Copy Caddyfile.example to the Caddy configuration. It routes /dist/* to /www/wwwroot/update.axlmc.org/dist, and /latest plus /api/* to Flask on 127.0.0.1:8082; Caddy automatically manages HTTPS certificates. Set DIST_ROOT=/www/wwwroot/update.axlmc.org/dist so files are stored as /dist/<version>/.... Run Flask behind Gunicorn:
gunicorn \
--workers 2 \
--worker-class sync \
--timeout 1800 \
--graceful-timeout 30 \
--bind 127.0.0.1:8082 \
wsgi:appBack up both the SQLite database (instance/update-server.db by default) and the complete DIST_ROOT tree. Never commit .env, database files, or artifacts. MAX_UPLOAD_SIZE limits manual request bodies and WEBHOOK_MAX_AGE_SECONDS limits replay windows. GitHub imports are bounded by GITHUB_DOWNLOAD_CONNECT_TIMEOUT_SECONDS, GITHUB_DOWNLOAD_READ_TIMEOUT_SECONDS, GITHUB_DOWNLOAD_RETRIES, and GITHUB_DOWNLOAD_MAX_SIZE.
The built-in rate limiter is process-local and intended as a baseline safeguard. With multiple Gunicorn workers, use a shared proxy/Redis limiter for consistent enforcement.
The normal request path is client -> Cloudflare -> Caddy -> Gunicorn. The app
resolves request.remote_addr through trusted proxy addresses before Flask runs,
so admin audit records and rate limiting use the client IP. Gunicorn's existing
%(h)s access-log field also uses this resolved address.
The default trusted networks are 127.0.0.1/32, ::1/128, and the official
Cloudflare IPv4/IPv6 ranges. X-Forwarded-For is read from right to left, stopping
at the first untrusted address. CF-Connecting-IP is used only when a Cloudflare
hop has been verified in that trusted chain. Invalid chains fall back to the
connection IP; a direct internet request cannot select an IP using these headers.
Keep Gunicorn bound to loopback. Docker/extra proxies need an explicit
comma-separated TRUSTED_PROXY_CIDRS containing only their actual addresses;
setting it empty disables forwarded-IP handling. It replaces the default list.
The updated Caddyfile.example supports older Caddy versions: it sets
X-Forwarded-For to {http.request.remote.host} and preserves
CF-Connecting-IP. Flask verifies that socket peer is a Cloudflare address
before using the CF header. Direct requests to Caddy use the socket IP.
Review the lists against Cloudflare IPv4
and IPv6 when updating deployment.
See Caddy trusted proxies
and Cloudflare headers.
Cloudflare Tunnel is a different proxy topology: configure its trusted local
connector explicitly rather than trusting arbitrary CF headers from loopback.
Only Gunicorn emits application HTTP access logs; Flask's duplicate JSON request logger has been removed. Its standard log line uses the real client IP:
203.0.113.12 - - [01/Nov/2026:20:41:39 +0800] "GET /latest HTTP/1.1" 200 1455 "-" "Axolotl-Launcher/1.10.4 (windows)"
Caddy emits JSON access logs to stderr for static /dist traffic that bypasses
Flask. Old Caddy versions may show the Cloudflare node IP in those separate logs.
Gunicorn access logs remain enabled by the systemd unit.
Historical audit rows are not rewritten because their original IP cannot be recovered.
After deploying the code, merge the example's proxy settings into your Caddyfile and validate before reload:
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy
sudo systemctl restart axolotl-update-server
sudo journalctl -u axolotl-update-server -f
# Static file requests:
sudo journalctl -u caddy -fThis IP/logging change adds no database migration. If updating from an older checkout, the existing systemd ExecStartPre runs any previously pending migrations.
LATEST_CACHE_SECONDS=30 enables a bounded per-process cache of /latest results,
including empty 204 responses. Keys contain the effective channel, platform and
client version. Committed Version/Artifact/ChannelPointer changes invalidate the
local cache and a shared instance-directory revision file invalidates other
workers. Rollbacks do not publish a new revision. A value of 0 disables caching.
The 200, 204 and 304 responses all include cache policy and Vary for
X-Axolotl-Channel, X-Axolotl-Platform, and X-Axolotl-Version. Query-only
requests return Cache-Control: public, max-age=0, s-maxage=30, must-revalidate
and Cloudflare-CDN-Cache-Control: public, max-age=30. Invalid requests and
requests with Authorization/Cookie are not shared-cacheable. Caddy must forward
these origin headers unchanged: remove its old /latest header Cache-Control
override. The sample Caddyfile already does so.
Cloudflare does not cache JSON by default and its default key does not separate
these custom headers. Vary alone is insufficient for Cloudflare. To cache
query-only requests, add a native Cache Rule limited to /latest without any
of the three variant headers, mark it Eligible for cache, retain the full query
string in the cache key and respect origin cache-control/TTL. Do not configure
a blanket Cache Everything rule ignoring query strings or variant headers.
Header-based clients use the origin cache by default (private and Cloudflare
no-store prevent cross-channel/platform leakage). If your Cloudflare plan
supports a native custom cache key, first include all three X-Axolotl headers
and all query parameters in that key, then set
LATEST_CDN_HEADER_CACHE_KEY=true and enable caching for /latest through a
native Cache Rule respecting origin TTL. No Worker deployment is needed.
See Cloudflare cache keys
and default cache behavior.
Origin invalidation is immediate after successful admin changes; CDN copies may
remain until the short TTL expires. Purge the CDN URL for an urgent revocation.
Use CF-Cache-Status and Age to verify actual edge hits; origin headers alone
do not establish that a Cloudflare rule is active.
Usage collection is disabled in code. To remove accumulated history, stop the
service and run the maintenance command with the same environment/database as
the service (the normal .env is loaded by the app):
cd /www/server/Axolotl-Update-Server
sudo systemctl stop axolotl-update-server
.venv/bin/flask --app wsgi:app purge-usage
sudo systemctl start axolotl-update-serverpurge-usage deletes only UsageEvent statistics, commits, runs SQLite VACUUM
and truncates the WAL checkpoint. Versions, artifact records/files, webhook
events and admin audit history remain intact. VACUUM rebuilds the database;
allow free disk space for temporary copies (up to twice the database size).
For a custom DATABASE_URL supplied outside .env, export the same value before
invoking Flask. Stop other processes accessing the database while compacting it.
If compaction fails, deletion is already committed and rerunning the command is
safe. Existing migration tables/schema are retained; no new migration is required.
To limit disk usage, a successful release webhook prunes published version directories after the database transaction completes. By default, RELEASE_RETENTION_COUNT=3 and BETA_RETENTION_COUNT=3; the retained set is the union of the newest three stable Release versions and newest three Beta prerelease versions. Database version and artifact metadata is preserved, while removed files are no longer eligible for /latest or /api/downloads/*. Configure the counts with environment variables when a different retention window is required. Draft/uploading versions and non-version directories are not removed.
deploy/axolotl-update-server.service runs as root from /www/server/Axolotl-Update-Server, loads /www/server/Axolotl-Update-Server/.env, runs migrations with .venv/bin/flask, and starts .venv/bin/gunicorn.
Create the virtual environment and install dependencies:
cd /www/server/Axolotl-Update-Server
python3 -m venv .venv
.venv/bin/pip install -r requirements.txtCopy .env.example to .env and set unique production secrets:
cd /www/server/Axolotl-Update-Server
cp .env.example .env
chmod 600 .envThe .env file is consumed by systemd's EnvironmentFile, so use plain KEY=value lines. Do not use export, shell substitutions, multiline values, or quotes that need shell interpretation. Use absolute production paths:
FLASK_ENV=production
SECRET_KEY=<unique-secret>
DATABASE_URL=sqlite:////www/server/Axolotl-Update-Server/instance/update-server.db
DIST_ROOT=/www/wwwroot/update.axlmc.org/dist
PUBLIC_BASE_URL=https://update.axlmc.org
UPDATE_SERVER_HOST=127.0.0.1
UPDATE_SERVER_PORT=8082
UPLOAD_TOKEN=<unique-upload-token>
WEBHOOK_SECRET=<unique-webhook-secret>
ADMIN_TOKEN=<unique-admin-token>
MAX_UPLOAD_SIZE=536870912
WEBHOOK_MAX_AGE_SECONDS=300
RATE_LIMIT_PER_MINUTE=120Ensure the database directory and DIST_ROOT exist:
mkdir -p /www/server/Axolotl-Update-Server/instance
mkdir -p /www/wwwroot/update.axlmc.org/distInstall and start the service:
sudo install -m 644 deploy/axolotl-update-server.service /etc/systemd/system/axolotl-update-server.service
sudo systemctl daemon-reload
sudo systemctl enable --now axolotl-update-server
sudo systemctl status axolotl-update-serverView logs and restart after changing .env, dependencies, or source files:
sudo journalctl -u axolotl-update-server -f
sudo systemctl restart axolotl-update-serverThe unit runs flask --app wsgi:app db upgrade before Gunicorn starts. Back up the SQLite database and DIST_ROOT before deploying a schema migration. Stop or drain old workers before applying 0008_artifact_target_platforms; a legacy worker must not publish a universal updater after the backfill. If migration fails, systemd does not start Gunicorn.
Initialize or upgrade the schema before production startup:
flask --app wsgi:app db upgradeThe repository includes an idempotent baseline migration and 0002_force_update. For future model changes, run flask --app wsgi:app db migrate -m "describe change", review the generated migration, then run flask --app wsgi:app db upgrade. Back up the database before upgrades; rollback is performed with flask --app wsgi:app db downgrade <revision> only after verifying a tested backup.
curl -X PUT --data-binary @Axolotl.tar.gz -H 'X-Upload-Token: upload-secret' -H 'X-Axolotl-Platform: linux-x86_64' -H 'X-Axolotl-Signature: base64-signature' https://update.axlmc.org/api/artifacts/1.10.0/Axolotl.tar.gz
curl -X PUT --data-binary @Axolotl-setup.exe -H 'X-Upload-Token: upload-secret' -H 'X-Axolotl-Platform: windows' -H 'X-Axolotl-Architecture: x86_64' -H 'X-Axolotl-Kind: installer' -H 'X-Axolotl-Variant: modern' -H 'X-Axolotl-Display-Name: Windows x64 Modern Installer' https://update.axlmc.org/api/artifacts/1.10.0/Axolotl-setup.exe
curl -H 'X-Axolotl-Channel: release' -H 'X-Axolotl-Platform: windows-x86_64' https://update.axlmc.org/latest
curl 'https://update.axlmc.org/api/downloads/latest?channel=release'
curl -X POST -H 'Authorization: Bearer admin-secret' -H 'Content-Type: application/json' -d '{"reason":"regression","operator":"admin"}' https://update.axlmc.org/api/admin/versions/1.10.0/revoke
curl -X POST -H 'Authorization: Bearer admin-secret' https://update.axlmc.org/api/admin/versions/1.10.0/restoreQuality checks: python -m pytest -q, ruff check app wsgi.py tests, and mypy app.