跳过正文
  1. 博客文章/

Outline 自用部署:Keycloak、MinIO 和 OpenResty

·1020 字·5 分钟·
DevOps Knowledge Base Outline Keycloak MinIO OpenResty PostgreSQL Docker-Compose LDAP
Zayn
作者
Zayn
专注 Kubernetes、CI/CD、可观测性等云原生技术栈,记录生产环境中的实战经验与踩坑复盘。
目录
生产变更复盘 - 这篇文章属于一个选集。
8: 本文
Outline 的部署难点不在容器启动,而在依赖链:数据库扩展、对象存储、OIDC、反向代理和证书要一起对齐。任何一环错了,表现都可能只是 Web 端登录失败或附件上传失败。
文中保留部署链路和截图入口;正文里的密码、OIDC Secret、MinIO 密钥和证书路径统一使用占位符。生产落地时只把真实值写进部署机 .env 或密钥管理系统。

0. 部署边界
#

这套 Outline 自用部署由几个组件组成:

组件角色
Outline知识库主服务
PostgreSQLOutline 和 Keycloak 数据库
RedisOutline 后台队列和缓存
MinIOOutline 附件对象存储
KeycloakOIDC 登录入口
OpenLDAP内部用户目录,可选
OpenRestyHTTPS 入口和反向代理
ALLinSSL证书申请和续期管理

整体链路如下:

flowchart LR
  Browser --> OpenResty
  OpenResty --> Outline
  OpenResty --> Keycloak
  OpenResty --> MinIO
  Outline --> PostgreSQL
  Outline --> Redis
  Outline --> MinIO
  Outline --> Keycloak
  Keycloak --> OpenLDAP

1. 证书先收口
#

证书由 ALLinSSL 统一管理,OpenResty 只引用证书文件。这样做的好处是证书续期和反向代理配置分开,后续迁移服务时也不用重新梳理每个站点的 ACME 逻辑。

ALLinSSL 证书管理入口

建议先准备这些域名:

域名用途
outline.example.comOutline 主入口
auth.example.comKeycloak OIDC 入口
minio-outline.example.comMinIO S3 API
console-minio-outline.example.comMinIO Console

上线前验证:

openssl x509 -in /etc/ssl/example/fullchain.pem -noout -dates -subject
nginx -t
systemctl reload openresty

2. PostgreSQL 扩展
#

Outline 依赖 PostgreSQL 扩展,常见包括 uuid-osspunaccentpg_trgm。如果 PostgreSQL 来自一键安装脚本或精简编译包,扩展可能需要手工安装。

数据库内先检查:

SELECT name, default_version, installed_version
FROM pg_available_extensions
WHERE name IN ('uuid-ossp', 'unaccent', 'pg_trgm');

创建扩展:

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

如果扩展文件不存在,需要回到 PostgreSQL 源码 contrib 目录编译安装:

sudo apt-get install -y libossp-uuid-dev
cd /data/tools/oneinstack/src/postgresql-12.7/contrib/uuid-ossp
make
make install
service postgresql restart

不要跳过这个检查。扩展缺失时,Outline 可能在迁移或启动阶段直接失败。

3. 数据库和账号
#

Keycloak 和 Outline 使用独立数据库与账号:

CREATE DATABASE keycloak_db;
CREATE USER keycloak_user WITH PASSWORD :'keycloak_db_password';
GRANT ALL PRIVILEGES ON DATABASE keycloak_db TO keycloak_user;

CREATE DATABASE outlinedb;
CREATE USER outlinedbuser WITH PASSWORD :'outline_db_password';
GRANT ALL PRIVILEGES ON DATABASE outlinedb TO outlinedbuser;

如果因为扩展创建权限需要临时提权,变更后要复查权限,不要长期让应用账号保留不必要的超级用户权限。

\c outlinedb
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

4. Keycloak 和 LDAP
#

Keycloak 可以参考单独的 Compose 项目部署。Outline 只关心 OIDC Client 这一层:

配置项示例
Client IDoutline
Redirect URIhttps://outline.example.com/auth/oidc.callback
Auth URIhttps://auth.example.com/realms/master/protocol/openid-connect/auth
Token URIhttps://auth.example.com/realms/master/protocol/openid-connect/token
UserInfo URIhttps://auth.example.com/realms/master/protocol/openid-connect/userinfo
Scopeopenid profile email
Username Claimemail

OpenLDAP 可作为 Keycloak 的用户源。示例 Compose:

services:
  openldap:
    image: osixia/openldap:latest
    restart: always
    environment:
      LDAP_ORGANISATION: "example"
      LDAP_DOMAIN: "example.com"
      LDAP_ADMIN_PASSWORD: "${LDAP_ADMIN_PASSWORD:?set LDAP_ADMIN_PASSWORD}"
      LDAP_CONFIG_PASSWORD: "${LDAP_CONFIG_PASSWORD:?set LDAP_CONFIG_PASSWORD}"
    volumes:
      - ./data:/var/lib/ldap
      - ./config:/etc/ldap/slapd.d
    ports:
      - "389:389"

  phpldapadmin:
    image: osixia/phpldapadmin:latest
    restart: always
    links:
      - openldap
    environment:
      PHPLDAPADMIN_HTTPS: "false"
      PHPLDAPADMIN_LDAP_HOSTS: "openldap"
    ports:
      - "30000:80"

真实 LDAP 管理员密码不要写进 Compose 文件,使用 .env 注入。

5. MinIO 和 Outline Compose
#

.env 示例:

OUTLINE_IMAGE_TAG=outlinewiki/outline:0.85.1
OUTLINE_MINIO_IMAGE_TAG=minio/minio:RELEASE.2023-08-04T17-40-21Z
OUTLINE_DB_NAME=outlinedb
OUTLINE_DB_USER=outlinedbuser
OUTLINE_DB_PASSWORD=CHANGE_ME_OUTLINE_DB_PASSWORD
OUTLINE_DB_PASSWORD_URLENCODED=CHANGE_ME_URL_ENCODED_OUTLINE_DB_PASSWORD
OUTLINE_DB_SSLMODE=require
OUTLINE_URL=https://outline.example.com
OUTLINE_HOSTNAME=outline.example.com
OUTLINE_MINIO_ADMIN=minioadmin
OUTLINE_MINIO_ADMIN_PASSWORD=CHANGE_ME_MINIO_ROOT_PASSWORD
OUTLINE_MINIO_USER=bucket.data
OUTLINE_MINIO_USER_PASSWORD=CHANGE_ME_MINIO_USER_PASSWORD
OUTLINE_MINIO_BUCKET_NAME=data
OUTLINE_MINIO_HOSTNAME=minio-outline.example.com
OUTLINE_MINIO_CONSOLE_URL=https://console-minio-outline.example.com
OUTLINE_SECRET_KEY=CHANGE_ME_OPENSSL_RAND_HEX_32
OUTLINE_UTILS_SECRET=CHANGE_ME_OPENSSL_RAND_HEX_32
OUTLINE_AWS_S3_UPLOAD_BUCKET_URL=https://minio-outline.example.com/data
OUTLINE_OIDC_CLIENT_SECRET=CHANGE_ME_OIDC_CLIENT_SECRET
OUTLINE_OIDC_AUTH_URI=https://auth.example.com/realms/master/protocol/openid-connect/auth
OUTLINE_OIDC_TOKEN_URI=https://auth.example.com/realms/master/protocol/openid-connect/token
OUTLINE_OIDC_USERINFO_URI=https://auth.example.com/realms/master/protocol/openid-connect/userinfo
OUTLINE_OIDC_DISPLAY_NAME=Keycloak
OUTLINE_OIDC_USERNAME_CLAIM=email
OUTLINE_OIDC_SCOPES=openid profile email
OUTLINE_REDIS_IMAGE_TAG=redis:7.0
FILE_STORAGE_UPLOAD_MAX_SIZE=26214400

DATABASE_URL 会把用户名、密码、主机和库名拼成 URI。数据库密码如果包含 @#:/?& 等字符,必须先做 URL 编码后写入 OUTLINE_DB_PASSWORD_URLENCODED。原始密码可以保留在 OUTLINE_DB_PASSWORD 里供人工维护,连接串只使用编码后的值。

生产环境必须优先让 PostgreSQL 开启 TLS,并保持 OUTLINE_DB_SSLMODE=require。不要在跨主机、公网、共享网络或不受控内网里使用 disable,否则数据库账号、会话和业务数据可能以明文在链路上传输。只有数据库和 Outline 在同一台主机、同一 Docker 网络,或链路已由专用内网/VPN 隔离且经过风险确认时,才允许临时改成 disable;变更记录里要写清楚隔离依据和恢复到 TLS 的时间点。

可以用下面的命令生成 URL 编码值:

python3 - <<'PY'
from urllib.parse import quote
import os

print(quote(os.environ["OUTLINE_DB_PASSWORD"], safe=""))
PY

生成两个 Outline secret:

openssl rand -hex 32
openssl rand -hex 32

Compose 主体。MinIO 初始化这段不要用 || true 吞掉关键失败;只对“已存在”这种幂等情况做显式判断。

services:
  minio:
    image: ${OUTLINE_MINIO_IMAGE_TAG}
    command: server /data --console-address :9001
    volumes:
      - ./data/minio:/data
    environment:
      MINIO_ROOT_USER: ${OUTLINE_MINIO_ADMIN}
      MINIO_ROOT_PASSWORD: ${OUTLINE_MINIO_ADMIN_PASSWORD}
      MINIO_BROWSER_REDIRECT_URL: ${OUTLINE_MINIO_CONSOLE_URL}
    ports:
      - "9000:9000"
      - "9001:9001"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 60s
    restart: always

  minio-init:
    image: ${OUTLINE_MINIO_IMAGE_TAG}
    depends_on:
      minio:
        condition: service_healthy
    entrypoint: /bin/sh
    command:
      - -c
      - |
        set -eu
        export MC_HOST_minio="http://${OUTLINE_MINIO_ADMIN}:${OUTLINE_MINIO_ADMIN_PASSWORD}@minio:9000"
        mc ls minio/${OUTLINE_MINIO_BUCKET_NAME} >/dev/null 2>&1 || mc mb minio/${OUTLINE_MINIO_BUCKET_NAME}
        mc admin user info minio ${OUTLINE_MINIO_USER} >/dev/null 2>&1 || mc admin user add minio ${OUTLINE_MINIO_USER} ${OUTLINE_MINIO_USER_PASSWORD}
        cat > outline-policy.json <<POLICY
        {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": [
                "s3:GetBucketLocation",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads"
              ],
              "Resource": ["arn:aws:s3:::${OUTLINE_MINIO_BUCKET_NAME}"]
            },
            {
              "Effect": "Allow",
              "Action": [
                "s3:AbortMultipartUpload",
                "s3:DeleteObject",
                "s3:GetObject",
                "s3:ListMultipartUploadParts",
                "s3:PutObject"
              ],
              "Resource": ["arn:aws:s3:::${OUTLINE_MINIO_BUCKET_NAME}/*"]
            }
          ]
        }
        POLICY
        mc admin policy info minio OutlineAppFullAccess >/dev/null 2>&1 || mc admin policy create minio OutlineAppFullAccess outline-policy.json
        if ! mc admin policy entities minio --user ${OUTLINE_MINIO_USER} | grep -q 'OutlineAppFullAccess'; then
          mc admin policy attach minio OutlineAppFullAccess --user=${OUTLINE_MINIO_USER}
        fi
        mc admin user info minio ${OUTLINE_MINIO_USER}
    environment:
      OUTLINE_MINIO_ADMIN: ${OUTLINE_MINIO_ADMIN}
      OUTLINE_MINIO_ADMIN_PASSWORD: ${OUTLINE_MINIO_ADMIN_PASSWORD}
      OUTLINE_MINIO_USER: ${OUTLINE_MINIO_USER}
      OUTLINE_MINIO_USER_PASSWORD: ${OUTLINE_MINIO_USER_PASSWORD}
      OUTLINE_MINIO_BUCKET_NAME: ${OUTLINE_MINIO_BUCKET_NAME}
    restart: "no"

  redis:
    image: ${OUTLINE_REDIS_IMAGE_TAG}
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 60s
    restart: always

  outline:
    image: ${OUTLINE_IMAGE_TAG}
    environment:
      DATABASE_URL: postgres://${OUTLINE_DB_USER}:${OUTLINE_DB_PASSWORD_URLENCODED}@postgres.example.internal:5432/${OUTLINE_DB_NAME}?sslmode=${OUTLINE_DB_SSLMODE}&connect_timeout=10
      PGSSLMODE: ${OUTLINE_DB_SSLMODE}
      REDIS_URL: redis://redis:6379
      URL: ${OUTLINE_URL}
      PORT: 3000
      SECRET_KEY: ${OUTLINE_SECRET_KEY}
      UTILS_SECRET: ${OUTLINE_UTILS_SECRET}
      FORCE_HTTPS: "false"
      ENABLE_UPDATES: "true"
      AWS_REGION: eu-east-1
      AWS_ACCESS_KEY_ID: ${OUTLINE_MINIO_USER}
      AWS_SECRET_ACCESS_KEY: ${OUTLINE_MINIO_USER_PASSWORD}
      AWS_S3_UPLOAD_BUCKET_URL: ${OUTLINE_AWS_S3_UPLOAD_BUCKET_URL}
      AWS_S3_ACCELERATE_URL: ${OUTLINE_AWS_S3_UPLOAD_BUCKET_URL}
      AWS_S3_UPLOAD_BUCKET_NAME: ${OUTLINE_MINIO_BUCKET_NAME}
      AWS_S3_UPLOAD_MAX_SIZE: ${FILE_STORAGE_UPLOAD_MAX_SIZE}
      FILE_STORAGE_UPLOAD_MAX_SIZE: ${FILE_STORAGE_UPLOAD_MAX_SIZE}
      AWS_S3_FORCE_PATH_STYLE: "false"
      AWS_S3_ACL: private
      OIDC_CLIENT_ID: outline
      OIDC_CLIENT_SECRET: ${OUTLINE_OIDC_CLIENT_SECRET}
      OIDC_AUTH_URI: ${OUTLINE_OIDC_AUTH_URI}
      OIDC_TOKEN_URI: ${OUTLINE_OIDC_TOKEN_URI}
      OIDC_USERINFO_URI: ${OUTLINE_OIDC_USERINFO_URI}
      OIDC_DISPLAY_NAME: ${OUTLINE_OIDC_DISPLAY_NAME}
      OIDC_USERNAME_CLAIM: ${OUTLINE_OIDC_USERNAME_CLAIM}
      OIDC_SCOPES: ${OUTLINE_OIDC_SCOPES}
    ports:
      - "3000:3000"
    healthcheck:
      test: timeout 10s bash -c ':> /dev/tcp/127.0.0.1/3000' || exit 1
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 90s
    restart: always
    depends_on:
      minio:
        condition: service_healthy
      redis:
        condition: service_healthy
      minio-init:
        condition: service_completed_successfully

启动前检查:

docker compose config
docker compose up -d
docker compose ps
docker compose logs --tail=100 outline

Review Gate:

检查项命令继续条件停止条件
.env 权限stat -c '%a %n' .env600 或更严格密钥文件可被普通用户读取
Compose 渲染docker compose config变量全部展开,服务完整变量缺失或 URL 拼错
MinIO bucketdocker compose exec minio mc ls minio/${OUTLINE_MINIO_BUCKET_NAME}bucket 存在bucket 不存在
MinIO 用户docker compose exec minio mc admin user info minio ${OUTLINE_MINIO_USER}用户存在且启用用户缺失或禁用
Outline 日志docker compose logs --tail=100 outline没有数据库/OIDC/S3 初始化错误关键依赖报错

6. OpenResty 反向代理
#

Outline 入口重点是 WebSocket 和转发头:

server {
  listen 443 ssl;
  server_name outline.example.com;

  ssl_certificate /etc/ssl/example/fullchain.pem;
  ssl_certificate_key /etc/ssl/example/privkey.pem;

  location / {
    proxy_pass http://127.0.0.1:3000/;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Scheme $scheme;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_redirect off;
  }
}

MinIO API 和 Console 分开代理。Console 要保留 WebSocket 头:

location / {
  proxy_set_header Host $http_host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_pass http://127.0.0.1:9001/;
}

Keycloak 入口要让 X-Forwarded-Proto 和外部 HTTPS 地址一致,否则 OIDC 回调容易跳错地址。

7. 验证矩阵
#

检查项命令 / 操作通过标准
PostgreSQL 扩展\dxuuid-osspunaccentpg_trgm 存在
Redisdocker compose exec redis redis-cli ping返回 PONG
MinIOcurl -f http://127.0.0.1:9000/minio/health/live返回 200
MinIO 权限用 Outline 上传附件,或用同一 AK/SK 上传测试对象对象写入成功
Outlinecurl -I http://127.0.0.1:3000有 HTTP 响应
OIDC浏览器点击 Keycloak 登录能跳转回 Outline
附件上传上传一张图片MinIO bucket 里能看到对象
HTTPScurl -I https://outline.example.com证书有效,状态正常

8. 回滚
#

备份关键数据:

pg_dump -Fc outlinedb > outlinedb-before-change.dump
tar czf outline-compose-backup-$(date +%F-%H%M%S).tar.gz .env compose.yaml data/minio

停止应用:

docker compose down

如果只是代理配置错误,优先回滚 OpenResty 配置并 reload,不要动数据库和对象存储。

nginx -t
systemctl reload openresty

如果 MinIO 初始化失败,先停 Outline,保留 data/minio 现场,再修复 bucket、用户和 policy。不要直接删除 MinIO 数据目录,除非已经确认没有用户附件。

9. 常见问题
#

Outline 启动失败,日志里提示数据库扩展缺失

回到 PostgreSQL 里创建 uuid-osspunaccentpg_trgm。如果 pg_available_extensions 里都没有,先补安装扩展文件。

登录 Keycloak 后回调失败

检查 Outline 的 URL、OIDC Redirect URI、OpenResty 转发头和 Keycloak hostname。四者必须按同一个外部 HTTPS 地址收敛。

附件上传失败

先查 MinIO bucket、访问密钥、AWS_S3_UPLOAD_BUCKET_URL、反向代理最大上传大小和浏览器控制台。Outline 到 MinIO 的服务端连接和浏览器到 MinIO 的外部 URL 都要通。

MinIO Console 打不开或卡住

多数是 WebSocket 头没传。Console 入口需要 UpgradeConnection "upgrade"

生产变更复盘 - 这篇文章属于一个选集。
8: 本文

相关文章

Docker Compose 部署 Keycloak 并接入 LDAP
·1010 字·5 分钟
LDAP SSO DevOps Identity Keycloak +3
用 Docker Compose 部署 Draw.io 并接入 GitLab 存储
·1060 字·5 分钟
SRE Nginx DevOps Draw.io GitLab +4
用 Docker Compose 部署 ALLinSSL 并接入 Nginx 反向代理
·564 字·3 分钟
SRE SSL TLS Nginx DevOps +4