diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 18da89938..8d0ddcffa 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -205,9 +205,10 @@ jobs:
- name: Publish install scripts
run: |
cp src/packaging/install-linux.sh pages/install-linux.sh
+ cp src/packaging/install-linux-binary.sh pages/install-linux-binary.sh
cp src/packaging/install-mac.sh pages/install-mac.sh
cp src/packaging/install-windows.ps1 pages/install-windows.ps1
- chmod +x pages/install-linux.sh pages/install-mac.sh
+ chmod +x pages/install-linux.sh pages/install-linux-binary.sh pages/install-mac.sh
- name: Write self-update checksum files
run: |
@@ -323,6 +324,10 @@ jobs:
Linuxrpm / deb / apk |
curl -fsSL https://buckit-io.github.io/buckit/install-linux.sh | sh |
+
+ | Linuxstandalone binary |
+ curl -fsSL https://buckit-io.github.io/buckit/install-linux-binary.sh | sh |
+
| macOSApple Silicon |
curl -fsSL https://buckit-io.github.io/buckit/install-mac.sh | sh |
diff --git a/README.md b/README.md
index 95b793760..a1ade6cc9 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,17 @@ SHA-256 checksum, and prints the package-manager command to run:
curl -fsSL https://buckit-io.github.io/buckit/install-linux.sh | sh
```
+### Linux Standalone Binary
+
+To run the server without a package or a systemd service — for example when
+taking over an existing deployment that is started by hand — download the
+binary on its own. The helper script verifies its SHA-256 checksum and leaves
+an executable `buckit` in the current directory:
+
+```sh
+curl -fsSL https://buckit-io.github.io/buckit/install-linux-binary.sh | sh
+```
+
### Build From Source
Buckit requires Go 1.25 or newer. If Go is not installed, download and install
diff --git a/packaging/install-linux-binary.sh b/packaging/install-linux-binary.sh
new file mode 100755
index 000000000..7bc5bc6b0
--- /dev/null
+++ b/packaging/install-linux-binary.sh
@@ -0,0 +1,221 @@
+#!/bin/sh
+# install-linux-binary.sh — Linux standalone-binary installer helper for buckit.
+#
+# Downloads the Linux buckit binary for the latest stable release to a
+# predictable filename (buckit), verifies its published SHA-256 checksum, and
+# leaves it in place so you can run it directly. It does NOT install it onto
+# your PATH, and it does NOT register a systemd service.
+#
+# For a package-managed install with a systemd service, use install-linux.sh
+# instead, which downloads the .deb/.rpm/.apk for this host.
+#
+# Usage:
+# curl -fsSL https://buckit-io.github.io/buckit/install-linux-binary.sh | sh
+# ./buckit --help # run it from where it was downloaded
+#
+# Environment overrides:
+# BUCKIT_PAGES_BASE gh-pages base URL
+# (default: https://buckit-io.github.io/buckit)
+# BUCKIT_RELEASE_BASE release download base
+# (default: https://github.com/buckit-io/buckit/releases/download)
+# BUCKIT_VERSION pin a release tag (e.g. RELEASE.2026-05-11T17-20-40Z)
+# instead of resolving the latest stable release
+# BUCKIT_DOWNLOAD_DIR directory to download the binary into
+# (default: the current directory)
+
+set -eu
+
+PAGES_BASE="${BUCKIT_PAGES_BASE:-https://buckit-io.github.io/buckit}"
+RELEASE_BASE="${BUCKIT_RELEASE_BASE:-https://github.com/buckit-io/buckit/releases/download}"
+
+# Set by resolve_release.
+TAG=""
+POINTER_SHA=""
+
+err() {
+ echo "install-linux-binary.sh: $*" >&2
+ exit 1
+}
+
+info() {
+ echo "==> $*"
+}
+
+# detect_platform requires Linux and sets ARCH to the Go-style arch tuple
+# (amd64 / arm64) matching the published release assets.
+detect_platform() {
+ os="$(uname -s)"
+ [ "$os" = "Linux" ] || err "this installer is for Linux (detected '$os'). On macOS use install-mac.sh; on Windows use install-windows.ps1"
+
+ arch="$(uname -m)"
+ case "$arch" in
+ x86_64 | amd64) ARCH="amd64" ;;
+ aarch64 | arm64) ARCH="arm64" ;;
+ *) err "unsupported architecture '$arch'. Only amd64 and arm64 builds are published." ;;
+ esac
+}
+
+# validate_tag rejects anything that is not a plain release identifier. Both
+# the pinned BUCKIT_VERSION and the pointer-resolved tag go through this, so a
+# value like '../../evil' cannot reach the download URL as path traversal.
+validate_tag() {
+ case "$1" in
+ RELEASE.?*) ;;
+ *) err "unexpected release tag '$1' (expected RELEASE.*)" ;;
+ esac
+ # Only the characters real release tags use. Rejects '/', whitespace,
+ # control characters, and URL delimiters such as '?' and '#'.
+ case "$1" in
+ *[!A-Za-z0-9._-]*) err "release tag '$1' contains unsupported characters" ;;
+ esac
+}
+
+# normalize_sha lowercases a hex digest and requires exactly 64 hex characters,
+# so a truncated or malformed checksum record can never be compared as if it
+# were a valid digest. Echoes the normalized digest.
+normalize_sha() {
+ _sha="$(printf '%s' "$1" | tr 'ABCDEF' 'abcdef')"
+ case "$_sha" in
+ "" | *[!0-9a-f]*) err "malformed sha256 digest: '$1'" ;;
+ esac
+ [ "${#_sha}" -eq 64 ] || err "malformed sha256 digest: '$1'"
+ printf '%s' "$_sha"
+}
+
+# fetch URL -> stdout
+fetch() {
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL "$1"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO- "$1"
+ else
+ err "need curl or wget to download"
+ fi
+}
+
+# fetch_to URL FILE
+fetch_to() {
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL "$1" -o "$2"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO "$2" "$1"
+ else
+ err "need curl or wget to download"
+ fi
+}
+
+# sha256_of FILE -> normalized hex digest on stdout. The hash utility runs on
+# its own rather than inside a pipeline, so a failure surfaces instead of being
+# masked by the exit status of a downstream parser.
+sha256_of() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ _hash_out="$(sha256sum "$1")" || err "sha256sum failed on $1"
+ elif command -v shasum >/dev/null 2>&1; then
+ _hash_out="$(shasum -a 256 "$1")" || err "shasum failed on $1"
+ else
+ err "need sha256sum or shasum to verify the download"
+ fi
+ normalize_sha "$(printf '%s\n' "$_hash_out" | awk 'NR==1{print $1}')"
+}
+
+# resolve_release sets TAG, and POINTER_SHA when the release was resolved from
+# the gh-pages pointer. A pinned BUCKIT_VERSION leaves POINTER_SHA empty: the
+# pointer only ever describes the latest release, so it cannot vouch for an
+# arbitrary pinned version.
+resolve_release() {
+ if [ -n "${BUCKIT_VERSION:-}" ]; then
+ validate_tag "$BUCKIT_VERSION"
+ TAG="$BUCKIT_VERSION"
+ POINTER_SHA=""
+ return
+ fi
+
+ pointer_url="$PAGES_BASE/server/buckit/release/linux-$ARCH/buckit.sha256sum"
+ # Pointer format: " buckit."
+ pointer="$(fetch "$pointer_url")" || err "could not fetch release pointer at $pointer_url"
+
+ name="$(printf '%s\n' "$pointer" | awk 'NR==1{print $2}')"
+ case "$name" in
+ buckit.*) ;;
+ *) err "unexpected release pointer payload: $pointer" ;;
+ esac
+
+ TAG="${name#buckit.}"
+ validate_tag "$TAG"
+ POINTER_SHA="$(normalize_sha "$(printf '%s\n' "$pointer" | awk 'NR==1{print $1}')")"
+}
+
+main() {
+ detect_platform
+ info "platform: linux-$ARCH"
+
+ resolve_release
+ [ -n "$TAG" ] || err "could not resolve a release tag"
+ info "release: $TAG"
+
+ asset="buckit-linux-$ARCH.$TAG"
+ download_url="$RELEASE_BASE/$TAG/$asset"
+
+ dldir="${BUCKIT_DOWNLOAD_DIR:-.}"
+ mkdir -p "$dldir"
+ binfile="$dldir/buckit"
+
+ # Refuse to run when the destination is a directory. 'mv' would move the
+ # temp file inside it and the script would report success while leaving
+ # nothing runnable at the path it prints.
+ [ ! -d "$binfile" ] || err "$binfile is a directory — remove it or set BUCKIT_DOWNLOAD_DIR"
+
+ # Download to a temporary sibling and only move it into the predictable
+ # path after the checksum verifies, so a failed or interrupted download
+ # can never clobber an existing good binary or leave a partial/unverified
+ # file at the path the printed command references.
+ tmpfile="$(mktemp "$dldir/.buckit.XXXXXX")" || err "could not create temp file in $dldir"
+ trap 'rm -f "$tmpfile"' EXIT
+
+ info "downloading $asset"
+ fetch_to "$download_url" "$tmpfile" || err "download failed: $download_url"
+
+ info "fetching published checksum"
+ # Capture the payload first: piping the fetch straight into a parser would
+ # hide a failed transfer behind the parser's exit status.
+ checksum_payload="$(fetch "$download_url.sha256sum")" ||
+ err "could not fetch checksum at $download_url.sha256sum"
+ release_sha="$(normalize_sha "$(printf '%s\n' "$checksum_payload" | awk 'NR==1{print $1}')")"
+
+ # The binary and the checksum beside it come from the same origin, so that
+ # digest alone only proves the download was not corrupted in transit. The
+ # gh-pages pointer publishes the same digest from a separate origin;
+ # when it is available, require the two to agree before trusting either.
+ if [ -n "$POINTER_SHA" ]; then
+ if [ "$POINTER_SHA" != "$release_sha" ]; then
+ err "published digests disagree (pages $POINTER_SHA, release $release_sha) — refusing to continue"
+ fi
+ want_sha="$POINTER_SHA"
+ info "sha256 cross-checked against the release pointer"
+ else
+ want_sha="$release_sha"
+ fi
+
+ got_sha="$(sha256_of "$tmpfile")"
+ if [ "$got_sha" != "$want_sha" ]; then
+ err "checksum mismatch (expected $want_sha, got $got_sha) — refusing to continue"
+ fi
+ info "sha256 verified"
+
+ chmod 0755 "$tmpfile"
+
+ mv -f "$tmpfile" "$binfile"
+ trap - EXIT
+
+ echo
+ echo "Downloaded and verified:"
+ echo " $binfile"
+ echo
+ echo "Run it from here:"
+ echo " \"$binfile\""
+ echo
+ echo "(Move it onto your PATH to call 'buckit' from anywhere.)"
+ echo
+}
+
+main "$@"