Initial packaging

This commit is contained in:
Codex
2025-11-12 08:43:03 -06:00
commit 3c0a730ef4
10 changed files with 420 additions and 0 deletions

44
BUILD.md Normal file
View 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 200MB 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View File

@@ -0,0 +1 @@