evgen@spb: ~/2026/08/nextcloud-nginx-php-fpm-collabora-coturn/

Nextcloud на своём сервере: nginx, PHP-FPM 8.3, Collabora и coturn

Задача: перевезти рабочий Nextcloud на новый сервер, попутно заменив Apache на nginx и приведя PHP в чувство. Тридцать пять пользователей, офисные документы онлайн, звонки в Talk.

Всё это делается по документации. Проблема в том, что документация описывает установку с нуля на чистую систему, а реальность — это перенос живого инстанса, где половина настроек унаследована от предшественника, а вторая половина потерялась при копировании.

Поэтому статья не столько про установку, сколько про грабли. Их набралось на полтора десятка, и почти каждая давала симптом, по которому причину не угадать.

Домен в примерах — cloud.example.com, публичный адрес сервера — 203.0.113.10, внутренний — 10.130.0.34.

Порядок работ

Чтобы не отлаживать всё разом, разумная последовательность такая:

  1. Базовая система: nginx, PHP-FPM, кэш, SSL. На этом этапе Nextcloud должен просто открываться.
  2. Восстановление того, что не переехало: скрытые файлы, права, настройки config.php.
  3. Пользователи.
  4. Collabora.
  5. notify_push и coturn — то, что нужно клиентам и звонкам, но не мешает работе, если отложить.

Я так и шёл, и это оказалось правильным решением: каждый следующий этап ломался по своим причинам, и разделение позволяло не путать их между собой.

Первое, что теряется при переезде: .htaccess

После копирования файлов Nextcloud встретил меня ошибками в разделе «Проверка безопасности». Причина простая: .htaccess и .user.ini — файлы скрытые, и при копировании некоторыми способами синхронизации они не поехали.

Может показаться, что при переходе на nginx .htaccess не нужен вовсе. Нужен: Nextcloud проверяет его наличие и содержимое сам, независимо от того, какой веб-сервер стоит, а .user.ini задаёт лимиты PHP.

Восстанавливаются из официального архива своей версии:

apt install -y bzip2
cd /tmp
wget https://download.nextcloud.com/server/releases/nextcloud-34.0.1.tar.bz2
tar xjf nextcloud-34.0.1.tar.bz2 nextcloud/.htaccess nextcloud/.user.ini
cp nextcloud/.htaccess nextcloud/.user.ini /var/www/html/
chown www-data:www-data /var/www/html/.htaccess /var/www/html/.user.ini

Версия архива должна совпадать с версией инстанса — файлы между релизами меняются.

Ошибка 500 сразу после переезда

Первый запуск — белый экран и 500. В логе nginx:

PHP Fatal error: Uncaught OCP\HintException: [0]:
Memcache OC\Memcache\APCu not available for local cache

config.php переехал вместе с сайтом и требует APCu, которого на новом сервере нет. Ставим кэш целиком, раз уж всё равно настраиваем окружение:

apt install -y php8.3-fpm redis-server \
  php8.3-{gd,mysql,curl,mbstring,intl,gmp,bcmath,xml,zip,imagick,apcu,redis}

Для APCu обязательна строка, разрешающая работу в CLI, иначе occ и cron-задания будут падать с той же ошибкой, что и веб:

; /etc/php/8.3/mods-available/apcu.ini
apc.enable_cli=1

Разделение ролей стандартное: APCu — локальный кэш, Redis — распределённый кэш и блокировки файлов.

'memcache.local'       => '\OC\Memcache\APCu',
'memcache.distributed' => '\OC\Memcache\Redis',
'memcache.locking'     => '\OC\Memcache\Redis',
'redis' => [
    'host' => '/var/run/redis/redis-server.sock',
    'port' => 0,
],

JIT, которого не было

Включил OPcache с JIT в /etc/php/8.3/fpm/php.ini, перезапустил, проверяю — JIT выключен. Проверяю ещё раз, уже подозревая в невнимательности себя, — выключен.

Разгадка в порядке загрузки конфигов. Файлы из conf.d читаются после основного php.ini и спокойно перебивают его значения. А в /etc/php/8.3/fpm/conf.d/10-opcache.ini, который приезжает вместе с пакетом, лежало opcache.jit=off.

Править надо именно этот файл:

; /etc/php/8.3/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
opcache.revalidate_freq=60
opcache.jit=1255
opcache.jit_buffer_size=64M

И проверять не содержимое файла, а эффективное значение:

php-fpm8.3 -i | grep -E "opcache.jit|jit_buffer"

Привычка смотреть на то, что PHP реально применил, а не на то, что записано в конфиге, экономит часы. Особенно в дистрибутивных сборках, где conf.d живёт своей жизнью.

nginx: два места, где легко ошибиться

Конфиг для Nextcloud есть в официальной документации, и брать надо оттуда — он большой и меняется от версии к версии. Но два блока стоит понимать отдельно, потому что именно они дают загадочные 403 и 404 уже после того, как сайт вроде бы заработал.

Скрытые файлы и .well-known. Классическое правило «резать всё, что начинается с точки» ломает WebFinger, и в проверках вылезает ошибка про /.well-known/webfinger:

# было
location ~ /\. { deny all; }

# стало
location ~ /\.(?!well-known) { deny all; }

Регулярка PHP-локации. Вариант с (?:$|/) в некоторых конфигурациях отдаёт PATH_INFO не целиком, и часть маршрутов Nextcloud отваливается. Рабочий вариант:

location ~ \.php(?:$|/.*) {
    fastcgi_split_path_info ^(.+?\.php)(/.*)$;
    set $path_info $fastcgi_path_info;
    try_files $fastcgi_script_name =404;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $path_info;
    fastcgi_param HTTPS on;
    fastcgi_param modHeadersAvailable true;
    fastcgi_param front_controller_active true;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    fastcgi_intercept_errors on;
    fastcgi_request_buffering off;
    fastcgi_max_temp_file_size 0;
}

Сертификат — обычный Let’s Encrypt через certbot, тут ничего интересного. Интересное начнётся ниже, когда до этого сертификата доберётся coturn.

Тридцать пять пользователей одной командой

Создавать учётки руками через веб-интерфейс — занятие для человека, которому некуда девать вечер. У occ есть user:add, пароль передаётся через переменную окружения OC_PASS.

И вот здесь ловушка, из-за которой скрипт сначала молча не работал: sudo -u www-data вычищает окружение. Переменная, экспортированная в скрипте, до occ не доезжает, и команда падает на требовании пароля.

Рабочий вариант — экспортировать переменную внутри той же оболочки, которая запускает occ:

#!/bin/bash
NC=/var/www/html

while IFS=';' read -r login name email pass; do
    sudo -u www-data bash -c "export OC_PASS='${pass}' && \
        php ${NC}/occ user:add --password-from-env \
        --display-name='${name}' \
        --group='Сотрудники' '${login}'"
    sudo -u www-data php ${NC}/occ user:setting "${login}" settings email "${email}"
done < users.csv

Формат users.csvlogin;Имя Фамилия;email;пароль. Файл после отработки удалить, а не оставить в /root до лучших времён: там лежат пароли всех сотрудников в открытом виде.

Collabora в Docker и WOPI allowlist

Онлайн-редактор поднимается контейнером:

docker run -t -d -p 127.0.0.1:9980:9980 \
  -e "aliasgroup1=https://cloud\\.example\\.com:443" \
  -e "username=admin" -e "password=<пароль>" \
  --restart always \
  --name collabora collabora/code

Порт публикуется только на loopback — наружу его отдаёт nginx:

location ^~ /browser {
    proxy_pass https://127.0.0.1:9980;
    proxy_set_header Host $host;
    proxy_ssl_verify off;
}
location ^~ /hosting/capabilities {
    proxy_pass https://127.0.0.1:9980;
    proxy_set_header Host $host;
    proxy_ssl_verify off;
}
location ^~ /hosting/discovery {
    proxy_pass https://127.0.0.1:9980;
    proxy_set_header Host $host;
    proxy_ssl_verify off;
}
location ^~ /cool {
    proxy_pass https://127.0.0.1:9980;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_ssl_verify off;
}

Проверка, что бэкенд жив:

curl -sk https://127.0.0.1:9980/hosting/discovery | head -3

Должен прийти XML, начинающийся с <wopi-discovery>. Если пришёл — с Collabora всё в порядке, и дальнейшие ошибки будут не про неё.

Дальше — две вещи, без которых при открытии документа вылезет «Неавторизованный хост WOPI».

Контейнер обращается к Nextcloud со своего адреса в докеровской сети (172.17.0.0/16), и этот адрес должен быть в списке разрешённых:

sudo -u www-data php /var/www/html/occ config:app:set richdocuments wopi_allowlist \
  --value="127.0.0.1/8,203.0.113.10/32,172.17.0.0/16"

Та же подсеть — в доверенных прокси в config.php:

'trusted_proxies' => ['127.0.0.1', '203.0.113.10', '172.17.0.0/16'],

И отдельно: приложение richdocumentscode — встроенный сервер редактирования — конфликтует с внешним Collabora. Если оно установлено, документы будут открываться то так, то этак:

sudo -u www-data php /var/www/html/occ app:disable richdocumentscode

Общее правило для этой связки: почти все ошибки Collabora сводятся к вопросу «с какого адреса компонент видит соседа». Проверять надо wopi_allowlist, trusted_proxies и overwrite.cli.url — в этом порядке.

notify_push

Без него клиенты опрашивают сервер по таймеру, с ним получают уведомления мгновенно и заметно снижают нагрузку на PHP-FPM. Бинарник идёт вместе с приложением notify_push, вешаем его юнитом:

# /etc/systemd/system/notify_push.service
[Unit]
Description=Nextcloud notify_push
After=network.target redis-server.service

[Service]
User=www-data
ExecStart=/var/www/html/apps/notify_push/bin/x86_64/notify_push /var/www/html/config/config.php --port 7867
Restart=on-failure

[Install]
WantedBy=multi-user.target
systemctl enable --now notify_push
sudo -u www-data php /var/www/html/occ notify_push:setup https://cloud.example.com/push

Команда notify_push:setup сама проверит доступность и подскажет, если nginx не проксирует нужный путь.

coturn: NAT и права на ключ

TURN нужен для звонков в Talk, когда участники за NAT. Две настройки отличают рабочий coturn от нерабочего.

Первая — формат external-ip на облачной виртуалке. У машины публичный адрес не на интерфейсе, а на границе сети, поэтому указывать надо пару «внешний/внутренний»:

# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
fingerprint
use-auth-secret
static-auth-secret=<секрет>
realm=cloud.example.com
external-ip=203.0.113.10/10.130.0.34
cert=/etc/letsencrypt/live/cloud.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/cloud.example.com/privkey.pem
no-cli

Вторая — доступ к приватному ключу. coturn работает не от root и по умолчанию читать ключи Let’s Encrypt не может. При этом служба стартует и выглядит живой: порт 3478 отвечает, а 5349 молчит.

chgrp -R turnserver /etc/letsencrypt/live /etc/letsencrypt/archive
chmod g+rx /etc/letsencrypt/live /etc/letsencrypt/archive
chmod g+r /etc/letsencrypt/archive/cloud.example.com/privkey*.pem

И ключевой момент, о котором вспоминают месяца через три, когда звонки внезапно ломаются: права слетают при обновлении сертификата. Поэтому сразу хук:

# /etc/letsencrypt/renewal-hooks/post/coturn.sh
#!/bin/bash
chgrp -R turnserver /etc/letsencrypt/live /etc/letsencrypt/archive
chmod g+r /etc/letsencrypt/archive/cloud.example.com/privkey*.pem
systemctl restart coturn
chmod +x /etc/letsencrypt/renewal-hooks/post/coturn.sh

Проверить, что TLS-порт реально ожил:

ss -tlnp | grep -E '3478|5349'
echo | openssl s_client -connect cloud.example.com:5349 2>/dev/null | openssl x509 -noout -dates

Мелочи, которые стоит сделать сразу

Swap. Даже если памяти хватает, четыре гигабайта подкачки с низкой агрессивностью спасают от OOM при пиковых операциях вроде массовой генерации превью:

fallocate -l 4G /swapfile && chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
sysctl -w vm.swappiness=10

InnoDB. Дефолтные 128 МБ буферного пула на реальной базе малы:

# /etc/mysql/mariadb.conf.d/50-server.cnf
innodb_buffer_pool_size = 512M

Cron. Через systemd-таймер, не через AJAX. AJAX-режим означает, что фоновые задачи выполняются, только когда кто-то открыл страницу.

Server ID. В «Проверке безопасности» вылезет требование его задать:

'server_id' => 'cloud01',

Несовместимые приложения. После обновления мажорной версии часть приложений отваливается молча и роняет интерфейс. У меня таким оказался side_menu — лечится occ app:disable.

Проверка после настройки

  • «Проверка безопасности» в админке чистая, кроме осознанно оставленных пунктов
  • occ status и occ config:list system отрабатывают без ошибок
  • php-fpm8.3 -i показывает, что OPcache и JIT реально включены
  • документ открывается в Collabora, правки сохраняются
  • десктопный клиент получает изменения мгновенно (значит, notify_push работает)
  • звонок между двумя участниками из разных сетей проходит
  • порт 5349 слушается и отдаёт актуальный сертификат
  • в nextcloud.log за сутки нет повторяющихся ошибок

Последний пункт удобно смотреть не глазами, а свёрткой по частоте:

cat /var/www/html/data/nextcloud.log | python3 -c "
import sys, json
from collections import Counter
msgs = Counter()
for line in sys.stdin:
    try:
        e = json.loads(line)
        if e.get('level', 0) >= 3:
            msgs[e.get('message','')[:80]] += 1
    except Exception:
        pass
for msg, cnt in msgs.most_common(10):
    print(f'{cnt}x {msg}')
"

Один и тот же текст, повторённый двести раз, обычно означает одну конкретную недонастройку, а не двести проблем.

Что осталось за кадром

Высокопроизводительный бэкенд для Talk (HPB) — он нужен для звонков больше чем на три-четыре участника и просится на отдельную машину. Двухфакторная аутентификация — решение организационное, а не техническое. Обе строчки так и висят у меня в «Проверке безопасности» жёлтым, и это осознанный выбор, а не забывчивость.

Мораль

Первое. Перенос — это не копирование. Скрытые файлы, права на ключи, зависимости от модулей PHP и хуки certbot не переезжают сами, а обнаруживаются по одному, обычно в порядке возрастания неочевидности.

Второе. В PHP надо смотреть на эффективное значение параметра, а не на строчку в конфиге. conf.d перебивает php.ini, и знать это лучше до, а не после часа отладки JIT.

Третье. Все проблемы связки Nextcloud и Collabora сводятся к одному вопросу: с какого адреса компонент видит соседа. Docker-подсеть в wopi_allowlist и trusted_proxies лечит девять ошибок из десяти.

Четвёртое, и самое живучее. Права на сертификат, выставленные руками, живут ровно до следующего продления. Всё, что зависит от файлов Let’s Encrypt и работает не от root, обязано иметь renewal-hook — иначе поломка придёт через два месяца, когда связь с настройкой уже забудется.

#nextcloud #nginx #php #docker #collabora #coturn

evgen@spb: ~/contacts
evgen@spbping -c1 evgen.info
64 bytes from evgen.info: отвечу в течение рабочего дня
evgen@spbcat contacts.txt
mail      ya@evgen.info
telegram  @EvgenOne
github    github.com/onegin
blog      evgen.info/blog
evgen@spbecho $ЗАЧЕМ_ПИСАТЬ
интересная инфраструктурная задача · вопрос по Proxmox или Icinga ·
желание обсудить, почему memtest молчал, а память была битая
evgen@spb
© 2026 Евгений Подолинский · Санкт-Петербург
синий здесь – не тот, о котором вы подумали.