Initial packaging
This commit is contained in:
44
BUILD.md
Normal file
44
BUILD.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# AFFiNE Cloudron Package Build Guide
|
||||
|
||||
## Prerequisites
|
||||
- Cloudron CLI logged into your Cloudron server (`cloudron login`)
|
||||
- Access to the Cloudron build service `builder.docker.due.ren`
|
||||
- Docker installed locally (required by the Cloudron build tooling)
|
||||
- Git repository checked out with this packaging folder
|
||||
- Cloudron admin credentials to install the resulting app image
|
||||
|
||||
## Build Commands
|
||||
```bash
|
||||
cloudron build \
|
||||
--set-build-service builder.docker.due.ren \
|
||||
--build-service-token e3265de06b1d0e7bb38400539012a8433a74c2c96a17955e \
|
||||
--set-repository andreasdueren/ente-cloudron \
|
||||
--tag 0.1.0
|
||||
```
|
||||
|
||||
## Deployment Steps
|
||||
1. Remove any previous dev install of AFFiNE on the Cloudron (always reinstall from scratch).
|
||||
2. Install the freshly built image:
|
||||
```bash
|
||||
cloudron install --location ente.due.ren --image andreasdueren/ente-cloudron:0.1.0
|
||||
```
|
||||
3. When prompted, confirm the app info and wait for Cloudron to report success (abort after ~30 seconds if installation stalls or errors to avoid hanging sessions).
|
||||
4. Visit `https://ente.due.ren` (or the chosen location) and sign in using Cloudron SSO.
|
||||
|
||||
## Testing Checklist
|
||||
- Open the app dashboard and ensure the landing page loads without 502/504 errors.
|
||||
- Create a workspace, add a note, upload an asset, and reload to confirm `/app/data` persistence.
|
||||
- Trigger OIDC login/logout flows to verify Cloudron SSO works (callback `/api/v1/session/callback`).
|
||||
- Send an invitation email to validate SMTP credentials wired from the Cloudron mail addon.
|
||||
- Inspect logs with `cloudron logs --app ente.due.ren -f` for migration output from `scripts/self-host-predeploy.js`.
|
||||
|
||||
## Troubleshooting
|
||||
- **Migrations hang**: restart the app; migrations rerun automatically before the server starts. Check PostgreSQL reachability via `cloudron exec --app <id> -- env | grep DATABASE_URL`.
|
||||
- **Login issues**: confirm the Cloudron OIDC client is enabled for the app and that the callback URL `/api/v1/session/callback` is allowed.
|
||||
- **Email failures**: verify the Cloudron sendmail addon is provisioned and that `MAILER_*` env vars show up inside the container (`cloudron exec --app <id> -- env | grep MAILER`).
|
||||
- **Large uploads rejected**: adjust `client_max_body_size` in `nginx.conf` if you routinely exceed 200 MB assets, then rebuild.
|
||||
|
||||
## Configuration Notes
|
||||
- Persistent config lives in `/app/data/config/config.json`. Modify values (e.g., Stripe, throttling) and restart the app; the file is backed up by Cloudron.
|
||||
- Uploaded files live in `/app/data/storage` and map to `~/.affine/storage` inside the runtime.
|
||||
- Default health check hits `/api/healthz`; customize via `CloudronManifest.json` if upstream changes.
|
||||
34
CloudronManifest.json
Normal file
34
CloudronManifest.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "pro.affine.cloudron",
|
||||
"title": "AFFiNE",
|
||||
"author": "AFFiNE Project",
|
||||
"description": "Next-gen knowledge base that blends docs, whiteboards, and databases for self-hosted teams.",
|
||||
"website": "https://affine.pro",
|
||||
"contactEmail": "support@affine.pro",
|
||||
"version": "0.1.0",
|
||||
"changelog": "Initial Cloudron packaging",
|
||||
"manifestVersion": 2,
|
||||
"minBoxVersion": "7.0.0",
|
||||
"httpPort": 3000,
|
||||
"addons": {
|
||||
"localstorage": {},
|
||||
"postgresql": {},
|
||||
"redis": {},
|
||||
"sendmail": {},
|
||||
"oidc": {
|
||||
"redirectUris": [
|
||||
"/api/v1/session/callback"
|
||||
],
|
||||
"loginRedirectUri": "/api/v1/session/callback"
|
||||
}
|
||||
},
|
||||
"memoryLimit": 2147483648,
|
||||
"healthCheckPath": "/api/healthz",
|
||||
"tags": [
|
||||
"notes",
|
||||
"knowledge-base",
|
||||
"whiteboard",
|
||||
"collaboration"
|
||||
],
|
||||
"postInstallMessage": "AFFiNE is connected to Cloudron single sign-on by default. Sign in with any Cloudron user to create your first workspace."
|
||||
}
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
FROM ghcr.io/toeverything/affine:stable AS upstream
|
||||
|
||||
FROM cloudron/base:5.0.0
|
||||
|
||||
ENV APP_CODE_DIR=/app/code \
|
||||
APP_DATA_DIR=/app/data \
|
||||
APP_RUNTIME_DIR=/run/affine \
|
||||
APP_TMP_DIR=/tmp/data \
|
||||
APP_BUILD_DIR=/app/code/affine \
|
||||
NODE_ENV=production \
|
||||
PORT=3010 \
|
||||
LD_PRELOAD=libjemalloc.so.2
|
||||
|
||||
RUN mkdir -p "$APP_CODE_DIR" "$APP_DATA_DIR" "$APP_RUNTIME_DIR" "$APP_TMP_DIR" && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends jq python3 ca-certificates curl openssl libjemalloc2 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# bring in the upstream runtime and packaged server artifacts
|
||||
COPY --from=upstream /usr/local /usr/local
|
||||
COPY --from=upstream /app "$APP_BUILD_DIR"
|
||||
|
||||
# configuration, launch scripts, and defaults
|
||||
COPY start.sh "$APP_CODE_DIR/start.sh"
|
||||
COPY run-affine.sh "$APP_CODE_DIR/run-affine.sh"
|
||||
COPY nginx.conf "$APP_CODE_DIR/nginx.conf"
|
||||
COPY supervisord.conf "$APP_CODE_DIR/supervisord.conf"
|
||||
COPY config.example.json "$APP_CODE_DIR/config.example.json"
|
||||
COPY tmp_data/ "$APP_TMP_DIR/"
|
||||
|
||||
RUN chmod +x "$APP_CODE_DIR/start.sh" "$APP_CODE_DIR/run-affine.sh" && \
|
||||
chown -R cloudron:cloudron "$APP_CODE_DIR" "$APP_DATA_DIR" "$APP_RUNTIME_DIR" "$APP_TMP_DIR"
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["/app/code/start.sh"]
|
||||
6
config.example.json
Normal file
6
config.example.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://github.com/toeverything/AFFiNE/releases/latest/download/config.schema.json",
|
||||
"server": {
|
||||
"name": "AFFiNE Self Hosted Server"
|
||||
}
|
||||
}
|
||||
69
nginx.conf
Normal file
69
nginx.conf
Normal file
@@ -0,0 +1,69 @@
|
||||
worker_processes auto;
|
||||
error_log /dev/stderr info;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 4096;
|
||||
|
||||
log_format cloudron '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent"';
|
||||
access_log /dev/stdout cloudron;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
upstream affine_upstream {
|
||||
server 127.0.0.1:3010;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
client_max_body_size 200m;
|
||||
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
location /static/ {
|
||||
alias /app/code/affine/static/;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri @affine_app;
|
||||
}
|
||||
|
||||
location = /api/v1/session/callback {
|
||||
proxy_pass http://affine_upstream;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri @affine_app;
|
||||
}
|
||||
|
||||
location @affine_app {
|
||||
proxy_pass http://affine_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
run-affine.sh
Normal file
15
run-affine.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR=${APP_BUILD_DIR:-/app/code/affine}
|
||||
cd "$APP_DIR"
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date --iso-8601=seconds)" "$*"
|
||||
}
|
||||
|
||||
log "Running AFFiNE pre-deployment migrations"
|
||||
node ./scripts/self-host-predeploy.js
|
||||
|
||||
log "Starting AFFiNE server"
|
||||
exec node ./dist/main.js
|
||||
180
start.sh
Normal file
180
start.sh
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_CODE_DIR=${APP_CODE_DIR:-/app/code}
|
||||
APP_DATA_DIR=${APP_DATA_DIR:-/app/data}
|
||||
APP_RUNTIME_DIR=${APP_RUNTIME_DIR:-/run/affine}
|
||||
APP_TMP_DIR=${APP_TMP_DIR:-/tmp/data}
|
||||
APP_BUILD_DIR=${APP_BUILD_DIR:-/app/code/affine}
|
||||
AFFINE_HOME=/home/cloudron/.affine
|
||||
export APP_CODE_DIR APP_DATA_DIR APP_RUNTIME_DIR APP_TMP_DIR APP_BUILD_DIR
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date --iso-8601=seconds)" "$*"
|
||||
}
|
||||
|
||||
require_env() {
|
||||
local var_name="$1"
|
||||
if [ -z "${!var_name:-}" ]; then
|
||||
echo "Environment variable ${var_name} is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_data_dirs() {
|
||||
log "Preparing persistent directories"
|
||||
mkdir -p "$APP_DATA_DIR/config" "$APP_DATA_DIR/storage" "$APP_DATA_DIR/logs" "$APP_RUNTIME_DIR" "$AFFINE_HOME"
|
||||
|
||||
if [ ! -f "$APP_DATA_DIR/config/config.json" ]; then
|
||||
log "Seeding default configuration"
|
||||
cp "$APP_TMP_DIR/config/config.json" "$APP_DATA_DIR/config/config.json"
|
||||
fi
|
||||
|
||||
local storage_contents=""
|
||||
if [ -d "$APP_DATA_DIR/storage" ]; then
|
||||
storage_contents=$(ls -A "$APP_DATA_DIR/storage" 2>/dev/null || true)
|
||||
fi
|
||||
if [ ! -d "$APP_DATA_DIR/storage" ] || [ -z "$storage_contents" ]; then
|
||||
cp -a "$APP_TMP_DIR/storage/." "$APP_DATA_DIR/storage/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
rm -rf "$AFFINE_HOME/config" "$AFFINE_HOME/storage"
|
||||
ln -sf "$APP_DATA_DIR/config" "$AFFINE_HOME/config"
|
||||
ln -sf "$APP_DATA_DIR/storage" "$AFFINE_HOME/storage"
|
||||
|
||||
chown -R cloudron:cloudron "$APP_DATA_DIR" "$APP_RUNTIME_DIR" /home/cloudron
|
||||
}
|
||||
|
||||
configure_database() {
|
||||
require_env CLOUDRON_POSTGRESQL_URL
|
||||
local db_url="$CLOUDRON_POSTGRESQL_URL"
|
||||
if [[ "$db_url" == postgres://* ]]; then
|
||||
db_url="postgresql://${db_url#postgres://}"
|
||||
fi
|
||||
export DATABASE_URL="$db_url"
|
||||
log "Configured PostgreSQL endpoint"
|
||||
}
|
||||
|
||||
configure_redis() {
|
||||
require_env CLOUDRON_REDIS_URL
|
||||
local redis_info
|
||||
redis_info=$(python3 - <<'PY'
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
url = os.environ.get('CLOUDRON_REDIS_URL')
|
||||
if not url:
|
||||
raise SystemExit('redis url missing')
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or 'localhost'
|
||||
port = parsed.port or 6379
|
||||
password = parsed.password or ''
|
||||
db = (parsed.path or '/0').lstrip('/') or '0'
|
||||
print(f"{host}\n{port}\n{password}\n{db}")
|
||||
PY
|
||||
)
|
||||
IFS=$'\n' read -r host port password db <<<"$redis_info"
|
||||
export REDIS_SERVER_HOST="$host"
|
||||
export REDIS_SERVER_PORT="$port"
|
||||
export REDIS_SERVER_PASSWORD="$password"
|
||||
export REDIS_URL="$CLOUDRON_REDIS_URL"
|
||||
export REDIS_SERVER_URL="$CLOUDRON_REDIS_URL"
|
||||
log "Configured Redis endpoint"
|
||||
}
|
||||
|
||||
configure_mail() {
|
||||
if [ -z "${CLOUDRON_MAIL_SMTP_SERVER:-}" ]; then
|
||||
log "Cloudron mail addon not configured, skipping SMTP setup"
|
||||
return
|
||||
fi
|
||||
export MAILER_HOST="$CLOUDRON_MAIL_SMTP_SERVER"
|
||||
export MAILER_PORT="${CLOUDRON_MAIL_SMTP_PORT:-587}"
|
||||
export MAILER_USER="${CLOUDRON_MAIL_SMTP_USERNAME:-}"
|
||||
export MAILER_PASSWORD="${CLOUDRON_MAIL_SMTP_PASSWORD:-}"
|
||||
export MAILER_SENDER="${CLOUDRON_MAIL_FROM:-AFFiNE <no-reply@cloudron.local>}"
|
||||
export MAILER_SERVERNAME="${MAILER_SERVERNAME:-AFFiNE Server}"
|
||||
log "Configured SMTP relay"
|
||||
}
|
||||
|
||||
configure_server_metadata() {
|
||||
if [ -n "${CLOUDRON_APP_ORIGIN:-}" ]; then
|
||||
export AFFINE_SERVER_EXTERNAL_URL="$CLOUDRON_APP_ORIGIN"
|
||||
local host
|
||||
host=$(python3 - <<PY
|
||||
from urllib.parse import urlparse
|
||||
import os
|
||||
url = os.environ.get('CLOUDRON_APP_ORIGIN', '')
|
||||
parsed = urlparse(url)
|
||||
print(parsed.hostname or '')
|
||||
PY
|
||||
)
|
||||
export AFFINE_SERVER_HOST="$host"
|
||||
if [[ "$CLOUDRON_APP_ORIGIN" == https://* ]]; then
|
||||
export AFFINE_SERVER_HTTPS=true
|
||||
else
|
||||
export AFFINE_SERVER_HTTPS=false
|
||||
fi
|
||||
fi
|
||||
export AFFINE_INDEXER_ENABLED=${AFFINE_INDEXER_ENABLED:-false}
|
||||
}
|
||||
|
||||
configure_auth() {
|
||||
if [ -n "${CLOUDRON_OIDC_CLIENT_ID:-}" ]; then
|
||||
export CLOUDRON_OIDC_IDENTIFIER="${CLOUDRON_OIDC_IDENTIFIER:-cloudron}"
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
config_path = Path(os.environ['APP_DATA_DIR']) / 'config' / 'config.json'
|
||||
data = json.loads(config_path.read_text())
|
||||
auth = data.setdefault('auth', {})
|
||||
providers = auth.setdefault('providers', {})
|
||||
oidc = providers.setdefault('oidc', {})
|
||||
oidc['clientId'] = os.environ.get('CLOUDRON_OIDC_CLIENT_ID', '')
|
||||
oidc['clientSecret'] = os.environ.get('CLOUDRON_OIDC_CLIENT_SECRET', '')
|
||||
oidc['issuer'] = os.environ.get('CLOUDRON_OIDC_ISSUER') or os.environ.get('CLOUDRON_OIDC_DISCOVERY_URL', '')
|
||||
args = oidc.setdefault('args', {})
|
||||
args.setdefault('scope', 'openid profile email')
|
||||
config_path.write_text(json.dumps(data, indent=2))
|
||||
PY
|
||||
log "Enabled Cloudron OIDC for AFFiNE"
|
||||
fi
|
||||
}
|
||||
|
||||
finalize_permissions() {
|
||||
chown -R cloudron:cloudron "$APP_DATA_DIR"
|
||||
}
|
||||
|
||||
update_server_config() {
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
config_path = Path(os.environ['APP_DATA_DIR']) / 'config' / 'config.json'
|
||||
data = json.loads(config_path.read_text())
|
||||
server = data.setdefault('server', {})
|
||||
origin = os.environ.get('CLOUDRON_APP_ORIGIN')
|
||||
if origin:
|
||||
parsed = urlparse(origin)
|
||||
server['externalUrl'] = origin
|
||||
server['host'] = parsed.hostname or ''
|
||||
server['https'] = parsed.scheme == 'https'
|
||||
config_path.write_text(json.dumps(data, indent=2))
|
||||
PY
|
||||
}
|
||||
|
||||
main() {
|
||||
export HOME=/home/cloudron
|
||||
prepare_data_dirs
|
||||
configure_database
|
||||
configure_redis
|
||||
configure_mail
|
||||
configure_server_metadata
|
||||
update_server_config
|
||||
configure_auth
|
||||
finalize_permissions
|
||||
log "Starting supervisor"
|
||||
exec /usr/bin/supervisord -c "$APP_CODE_DIR/supervisord.conf"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
30
supervisord.conf
Normal file
30
supervisord.conf
Normal file
@@ -0,0 +1,30 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/null
|
||||
pidfile=/run/supervisord.pid
|
||||
|
||||
[program:nginx]
|
||||
command=/usr/sbin/nginx -c /app/code/nginx.conf -g 'daemon off;'
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=10
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopsignal=QUIT
|
||||
|
||||
[program:affine]
|
||||
command=/app/code/run-affine.sh
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=5
|
||||
priority=20
|
||||
user=cloudron
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
||||
stopsignal=TERM
|
||||
6
tmp_data/config/config.json
Normal file
6
tmp_data/config/config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://github.com/toeverything/AFFiNE/releases/latest/download/config.schema.json",
|
||||
"server": {
|
||||
"name": "AFFiNE Self Hosted Server"
|
||||
}
|
||||
}
|
||||
1
tmp_data/storage/.keep
Normal file
1
tmp_data/storage/.keep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user