feat(docker): add one-command installer

This commit is contained in:
tianma-if committed 2026-08-19 11:37:01 +08:00
1 parent 6fafbe1dba
commit ff41c5091a
9 files changed
+503 -11

No files matched your search

@@ -0,0 +1,61 @@
name: Sync Docker installer mirror
on:
push:
branches:
- main
paths:
- "apps/site/public/install.sh"
- "apps/site/public/compose.yaml"
- ".github/workflows/sync-docker-installer.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
sync:
if: github.repository == 'tianma-if/edgeever'
name: Publish Tencent COS mirror
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install Tencent COSCMD
run: |
python3 -m venv "${RUNNER_TEMP}/coscmd"
"${RUNNER_TEMP}/coscmd/bin/pip" install --disable-pip-version-check 'coscmd==1.9.0.6'
echo "${RUNNER_TEMP}/coscmd/bin" >> "${GITHUB_PATH}"
- name: Configure COSCMD
env:
COS_BUCKET: ${{ vars.TENCENT_COS_INSTALLER_BUCKET }}
COS_REGION: ${{ vars.TENCENT_COS_INSTALLER_REGION }}
COS_SECRET_ID: ${{ secrets.TENCENT_COS_INSTALLER_SECRET_ID }}
COS_SECRET_KEY: ${{ secrets.TENCENT_COS_INSTALLER_SECRET_KEY }}
run: |
test -n "${COS_BUCKET}"
test -n "${COS_REGION}"
test -n "${COS_SECRET_ID}"
test -n "${COS_SECRET_KEY}"
coscmd config \
-a "${COS_SECRET_ID}" \
-s "${COS_SECRET_KEY}" \
-b "${COS_BUCKET}" \
-r "${COS_REGION}"
- name: Upload installer files
run: |
coscmd upload -f apps/site/public/install.sh install.sh
coscmd upload -f apps/site/public/compose.yaml compose.yaml
- name: Verify public mirror
env:
COS_BUCKET: ${{ vars.TENCENT_COS_INSTALLER_BUCKET }}
COS_REGION: ${{ vars.TENCENT_COS_INSTALLER_REGION }}
run: |
base_url="https://${COS_BUCKET}.cos.${COS_REGION}.myqcloud.com"
curl --fail --silent --show-error "${base_url}/install.sh" | cmp apps/site/public/install.sh -
curl --fail --silent --show-error "${base_url}/compose.yaml" | cmp apps/site/public/compose.yaml -
+8 -4
View File
@@ -95,12 +95,16 @@ Complete setup in 5 simple web steps:
### Option C: Docker on a VPS or NAS
```sh
export EDGE_EVER_VERSION=vX.Y.Z
export EDGE_EVER_AUTH_PASSWORD='replace-with-a-long-random-password'
docker compose up -d
curl -fsSL https://edgeever.org/install.sh | bash
```
Docker persists SQLite and local attachments in `/data` and supports S3-compatible storage. Users in mainland China can select the Tencent Cloud mirror with `EDGE_EVER_IMAGE=ccr.ccs.tencentyun.com/edgeever/edgeever`. See the [Docker deployment guide](docs/deploy-docker.md) for details.
The script pulls the latest image, generates an administrator password, and starts EdgeEver with Docker Compose. Users in mainland China can use the Tencent COS and TCR mirrors:
```sh
curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --mirror tcr
```
See the [Docker deployment guide](docs/deploy-docker.md) for manual deployment and configuration.
---
+2 -4
View File
@@ -95,12 +95,10 @@ Cloudflare 在线部署可以选择以下两种方式之一:
### 方案三:在 VPS 或 NAS 上使用 Docker
```sh
export EDGE_EVER_VERSION=vX.Y.Z
export EDGE_EVER_AUTH_PASSWORD='请替换为足够长的随机密码'
docker compose up -d
curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --mirror tcr
```
Docker 将 SQLite 与本地附件持久化到 `/data`,并支持 S3 兼容存储。中国大陆用户可通过 `EDGE_EVER_IMAGE=ccr.ccs.tencentyun.com/edgeever/edgeever` 选择腾讯云镜像。详见 [Docker 部署文档](docs/deploy-docker.zh-CN.md)。
脚本和 Compose 配置由腾讯云 COS 提供中国大陆入口,并自动从腾讯云 TCR 拉取最新镜像、生成管理员密码及启动 EdgeEver。手动部署与配置说明见 [Docker 部署文档](docs/deploy-docker.zh-CN.md)。
---
+20
View File
@@ -0,0 +1,20 @@
services:
edgeever:
image: "${EDGE_EVER_IMAGE:-ghcr.io/tianma-if/edgeever}:${EDGE_EVER_VERSION:-latest}"
restart: unless-stopped
init: true
ports:
- "${EDGE_EVER_PORT:-8787}:8787"
environment:
EDGE_EVER_AUTH_USERNAME: "${EDGE_EVER_AUTH_USERNAME:-admin}"
EDGE_EVER_AUTH_PASSWORD: "${EDGE_EVER_AUTH_PASSWORD:?Set EDGE_EVER_AUTH_PASSWORD before starting EdgeEver}"
volumes:
- edgeever-data:/data
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stop_grace_period: 30s
volumes:
edgeever-data:
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
readonly GHCR_IMAGE="ghcr.io/tianma-if/edgeever"
readonly TCR_IMAGE="ccr.ccs.tencentyun.com/edgeever/edgeever"
readonly GLOBAL_COMPOSE_URL="https://edgeever.org/compose.yaml"
readonly TENCENT_COMPOSE_URL="https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/compose.yaml"
install_dir="${EDGE_EVER_INSTALL_DIR:-${HOME:-}/edgeever}"
compose_url="${EDGE_EVER_COMPOSE_URL:-}"
image="${EDGE_EVER_IMAGE:-}"
version="${EDGE_EVER_VERSION:-}"
port="${EDGE_EVER_PORT:-}"
username="${EDGE_EVER_AUTH_USERNAME:-}"
password="${EDGE_EVER_AUTH_PASSWORD:-}"
project_name="${EDGE_EVER_PROJECT_NAME:-edgeever}"
generated_password=false
temporary_compose=""
temporary_env=""
usage() {
cat <<'EOF'
Install or upgrade EdgeEver with Docker Compose.
Usage:
curl -fsSL https://edgeever.org/install.sh | bash
curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --mirror tcr
Options:
--mirror ghcr|tcr Select the image registry (default: ghcr)
--image IMAGE Use a custom image repository
--version TAG Deploy an image tag (default: latest)
--compose-url URL Download Compose configuration from URL
--install-dir DIR Store Compose configuration in DIR (default: ~/edgeever)
--port PORT Publish EdgeEver on PORT (default: 8787)
-h, --help Show this help
The same options can be provided with EDGE_EVER_IMAGE, EDGE_EVER_VERSION,
EDGE_EVER_COMPOSE_URL, EDGE_EVER_INSTALL_DIR, EDGE_EVER_PORT, and
EDGE_EVER_AUTH_PASSWORD.
EOF
}
fail() {
printf 'EdgeEver installer: %s\n' "$*" >&2
exit 1
}
on_error() {
local exit_code=$?
printf 'EdgeEver installer: command failed at line %s\n' "${BASH_LINENO[0]}" >&2
exit "$exit_code"
}
trap on_error ERR
require_value() {
[[ $# -ge 2 && -n "$2" ]] || fail "$1 requires a value"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--mirror)
require_value "$@"
case "$2" in
ghcr) image="$GHCR_IMAGE" ;;
tcr) image="$TCR_IMAGE" ;;
*) fail "--mirror must be ghcr or tcr" ;;
esac
shift 2
;;
--image)
require_value "$@"
image="$2"
shift 2
;;
--version)
require_value "$@"
version="$2"
shift 2
;;
--compose-url)
require_value "$@"
compose_url="$2"
shift 2
;;
--install-dir)
require_value "$@"
install_dir="$2"
shift 2
;;
--port)
require_value "$@"
port="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*) fail "unknown option: $1" ;;
esac
done
[[ -n "$install_dir" ]] || fail "set HOME or EDGE_EVER_INSTALL_DIR"
[[ "$project_name" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "invalid EDGE_EVER_PROJECT_NAME"
command -v curl >/dev/null 2>&1 || fail "curl is required"
command -v docker >/dev/null 2>&1 || fail "Docker is required: https://docs.docker.com/engine/install/"
docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is required"
docker info >/dev/null 2>&1 || fail "cannot connect to the Docker daemon"
mkdir -p "$install_dir"
env_file="$install_dir/.env"
compose_file="$install_dir/compose.yaml"
read_env_value() {
local key="$1"
local line value
[[ -f "$env_file" ]] || return 1
while IFS= read -r line; do
case "$line" in
"$key="*)
value="${line#*=}"
if [[ "$value" == "'"*"'" && ${#value} -ge 2 ]]; then
value="${value:1:${#value}-2}"
fi
printf '%s' "$value"
return 0
;;
esac
done < "$env_file"
return 1
}
if [[ -z "$image" ]]; then
image="$(read_env_value EDGE_EVER_IMAGE || true)"
image="${image:-$GHCR_IMAGE}"
fi
if [[ -z "$compose_url" ]]; then
if [[ "$image" == "$TCR_IMAGE" ]]; then
compose_url="$TENCENT_COMPOSE_URL"
else
compose_url="$GLOBAL_COMPOSE_URL"
fi
fi
if [[ -z "$version" ]]; then
version="$(read_env_value EDGE_EVER_VERSION || true)"
version="${version:-latest}"
fi
if [[ -z "$port" ]]; then
port="$(read_env_value EDGE_EVER_PORT || true)"
port="${port:-8787}"
fi
if [[ -z "$username" ]]; then
username="$(read_env_value EDGE_EVER_AUTH_USERNAME || true)"
username="${username:-admin}"
fi
if [[ -z "$password" ]]; then
password="$(read_env_value EDGE_EVER_AUTH_PASSWORD || true)"
fi
if [[ -z "$password" ]]; then
if command -v openssl >/dev/null 2>&1; then
password="$(openssl rand -hex 16)"
elif command -v od >/dev/null 2>&1; then
password="$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
else
fail "openssl or od is required to generate a password"
fi
generated_password=true
fi
[[ "$image" != *[[:space:]]* ]] || fail "image must not contain whitespace"
[[ "$version" != *[[:space:]]* ]] || fail "version must not contain whitespace"
[[ "$port" =~ ^[0-9]+$ ]] && ((port >= 1 && port <= 65535)) || fail "port must be between 1 and 65535"
[[ "$username" != *$'\n'* && "$username" != *"'"* ]] || fail "invalid administrator username"
[[ "$password" != *$'\n'* && "$password" != *"'"* ]] || fail "password must not contain a newline or single quote"
cleanup() {
[[ -z "$temporary_compose" || ! -e "$temporary_compose" ]] || rm -f "$temporary_compose"
[[ -z "$temporary_env" || ! -e "$temporary_env" ]] || rm -f "$temporary_env"
return 0
}
trap cleanup EXIT
temporary_compose="$(mktemp "$install_dir/.compose.yaml.XXXXXX")"
curl --fail --silent --show-error --location --output "$temporary_compose" "$compose_url"
grep -q '^services:' "$temporary_compose" || fail "downloaded Compose file is invalid"
chmod 0644 "$temporary_compose"
mv "$temporary_compose" "$compose_file"
temporary_compose=""
temporary_env="$(mktemp "$install_dir/.env.XXXXXX")"
{
printf "EDGE_EVER_IMAGE='%s'\n" "$image"
printf "EDGE_EVER_VERSION='%s'\n" "$version"
printf "EDGE_EVER_PORT='%s'\n" "$port"
printf "EDGE_EVER_AUTH_USERNAME='%s'\n" "$username"
printf "EDGE_EVER_AUTH_PASSWORD='%s'\n" "$password"
} > "$temporary_env"
chmod 0600 "$temporary_env"
mv "$temporary_env" "$env_file"
temporary_env=""
compose=(
docker compose
--project-name "$project_name"
--project-directory "$install_dir"
--env-file "$env_file"
--file "$compose_file"
)
printf 'Pulling %s:%s...\n' "$image" "$version"
"${compose[@]}" pull
"${compose[@]}" up -d --remove-orphans
container_id=""
health=""
for _ in {1..60}; do
container_id="$("${compose[@]}" ps -q edgeever 2>/dev/null || true)"
if [[ -n "$container_id" ]]; then
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || true)"
[[ "$health" == "healthy" ]] && break
[[ "$health" == "exited" || "$health" == "dead" ]] && break
fi
sleep 2
done
if [[ "$health" != "healthy" ]]; then
"${compose[@]}" logs --tail 80 edgeever >&2 || true
fail "container did not become healthy (status: ${health:-unknown})"
fi
printf '\nEdgeEver is ready.\n'
printf 'URL: http://<server-ip>:%s\n' "$port"
printf 'Username: %s\n' "$username"
if [[ "$generated_password" == true ]]; then
printf 'Password: %s\n' "$password"
else
printf 'Password: unchanged (stored in %s)\n' "$env_file"
fi
printf 'Install directory: %s\n' "$install_dir"
+24 -1
View File
@@ -13,7 +13,30 @@ Cloudflare uses Workers with D1 and R2.
- A reverse proxy with HTTPS when the instance is reachable outside a trusted
local network.
## Start with Compose
## One-command install
With Docker Compose v2 already installed:
```sh
curl -fsSL https://edgeever.org/install.sh | bash
```
Use Tencent Cloud TCR in mainland China:
```sh
curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --mirror tcr
```
The installer creates `~/edgeever`, generates an administrator password, pulls
`latest`, starts the container, and waits for it to become healthy. Run the same
command again to upgrade without replacing the password or `/data` volume. The
mainland command downloads the installer and Compose configuration from Tencent
COS and pulls the image from Tencent TCR.
Use `--version vX.Y.Z`, `--port PORT`, or `--install-dir DIR` when needed. Run
`curl -fsSL https://edgeever.org/install.sh | bash -s -- --help` for all options.
## Manual Compose
Download `compose.yaml`, choose the release you want to run, and provide a
strong instance password:
+20 -1
View File
@@ -11,7 +11,26 @@ Cloudflare 使用 Workers + D1 + R2。
- `amd64` 或 `arm64` Linux 主机。
- 实例离开可信局域网时,必须使用带 HTTPS 的反向代理。
## 使用 Compose 启动
## 一键安装
已安装 Docker Compose v2 的主机可直接执行:
```sh
curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --mirror tcr
```
脚本会创建 `~/edgeever`、生成管理员密码、拉取 `latest`、启动容器并等待健康
检查通过。中国大陆命令从腾讯云 COS 获取脚本与 Compose 配置,并从腾讯云 TCR
拉取镜像。再次执行同一命令即可升级,已有密码和 `/data` 卷保持不变。
海外服务器可去掉 `--mirror tcr` 使用 GHCR。按需使用 `--version vX.Y.Z`、
`--port PORT` 或 `--install-dir DIR`;执行以下命令可查看全部参数:
```sh
curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --help
```
## 手动使用 Compose
下载 `compose.yaml`,选择要运行的正式版本,并设置高强度实例密码:
+1 -1
View File
@@ -22,7 +22,7 @@
"dev:api:demo": "bun scripts/local-dev.mjs api demo",
"dev:self-hosted": "bun --watch scripts/self-hosted-server.mjs",
"start:self-hosted": "bun scripts/self-hosted-server.mjs",
"test:docker-release": "bun test scripts/docker-release.test.mjs scripts/build-cloudflare-worker.test.mjs tests/storage-adapter.test.ts",
"test:docker-release": "bun test scripts/docker-release.test.mjs scripts/docker-installer.test.mjs scripts/build-cloudflare-worker.test.mjs tests/storage-adapter.test.ts",
"dev:remote": "concurrently -k -n api,web -c cyan,magenta \"bun run dev:api:remote\" \"bun run dev:web\"",
"dev:api:remote": "bun scripts/run-wrangler.mjs dev --remote --port 8787",
"dev:web": "vite --config apps/web/vite.config.ts --host 127.0.0.1 --port 5173",
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
const projectRoot = resolve(import.meta.dir, "..");
const installerPath = resolve(projectRoot, "apps/site/public/install.sh");
const composePath = resolve(projectRoot, "compose.yaml");
const hostedComposePath = resolve(projectRoot, "apps/site/public/compose.yaml");
describe("Docker installer", () => {
test("publishes the repository Compose file unchanged", async () => {
expect(await readFile(hostedComposePath, "utf8")).toBe(await readFile(composePath, "utf8"));
});
test("installs with TCR and preserves the generated password on rerun", async () => {
const fixture = await mkdtemp(resolve(tmpdir(), "edgeever-installer-"));
const fakeBin = resolve(fixture, "bin");
const installDirectory = resolve(fixture, "edgeever");
const dockerLog = resolve(fixture, "docker.log");
const curlLog = resolve(fixture, "curl.log");
const fakeDocker = resolve(fakeBin, "docker");
const fakeCurl = resolve(fakeBin, "curl");
try {
await mkdir(fakeBin, { recursive: true });
await writeFile(
fakeDocker,
`#!/usr/bin/env bash
set -eu
printf '%s\\n' "$*" >> "$EDGE_EVER_TEST_DOCKER_LOG"
if [[ "$1" == "inspect" ]]; then
printf 'healthy\\n'
elif [[ "$1" == "compose" && "$*" == *" ps -q edgeever"* ]]; then
printf 'edgeever-test-container\\n'
fi
`,
);
await writeFile(
fakeCurl,
`#!/usr/bin/env bash
set -eu
printf '%s\n' "$*" >> "$EDGE_EVER_TEST_CURL_LOG"
output=''
while [[ $# -gt 0 ]]; do
if [[ "$1" == "--output" ]]; then
output="$2"
shift 2
else
shift
fi
done
cp "$EDGE_EVER_TEST_COMPOSE_SOURCE" "$output"
`,
);
await chmod(fakeDocker, 0o755);
await chmod(fakeCurl, 0o755);
const environment = {
...process.env,
PATH: `${fakeBin}:${process.env.PATH}`,
EDGE_EVER_INSTALL_DIR: installDirectory,
EDGE_EVER_TEST_COMPOSE_SOURCE: composePath,
EDGE_EVER_TEST_CURL_LOG: curlLog,
EDGE_EVER_TEST_DOCKER_LOG: dockerLog,
};
const first = spawnSync("bash", [installerPath, "--mirror", "tcr", "--port", "18789"], {
env: environment,
encoding: "utf8",
stdio: ["ignore", "ignore", "pipe"],
});
if (first.status !== 0) {
const commands = await readFile(dockerLog, "utf8").catch(() => "no Docker commands");
throw new Error(
`installer failed (exit=${first.status}, signal=${first.signal}):\n${first.stderr}\n${commands}`,
);
}
expect(first.status).toBe(0);
const firstEnvironment = await readFile(resolve(installDirectory, ".env"), "utf8");
expect(firstEnvironment).toContain(
"EDGE_EVER_IMAGE='ccr.ccs.tencentyun.com/edgeever/edgeever'",
);
expect(firstEnvironment).toContain("EDGE_EVER_VERSION='latest'");
expect(firstEnvironment).toContain("EDGE_EVER_PORT='18789'");
expect(firstEnvironment).toMatch(/EDGE_EVER_AUTH_PASSWORD='[a-f0-9]{32}'/);
const second = spawnSync("bash", [installerPath], {
env: environment,
encoding: "utf8",
stdio: ["ignore", "ignore", "pipe"],
});
if (second.status !== 0) {
throw new Error(
`installer rerun failed (exit=${second.status}, signal=${second.signal}):\n${second.stdout}\n${second.stderr}`,
);
}
expect(second.status).toBe(0);
expect(await readFile(resolve(installDirectory, ".env"), "utf8")).toBe(firstEnvironment);
const log = await readFile(dockerLog, "utf8");
expect(log).toContain("compose version");
expect(log.match(/ pull$/gm)).toHaveLength(2);
expect(log.match(/ up -d --remove-orphans$/gm)).toHaveLength(2);
expect(log).toContain("inspect --format");
const curlRequests = await readFile(curlLog, "utf8");
expect(
curlRequests.match(
/edgeever-installer-1256854452\.cos\.ap-guangzhou\.myqcloud\.com\/compose\.yaml/g,
),
).toHaveLength(2);
} finally {
await rm(fixture, { recursive: true, force: true });
}
});
});