diff --git a/README.md b/README.md index 7fa7a37..ccbb111 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,29 @@ cp .env.example .env ```bash ./scripts/test.sh ``` +## Move Nextcloud data directory to external storage +Use the migration helper to move existing data to a host path and switch the app to a bind mount. +Default target: +- `/media/rbhat/DATA/nextcloud/NextCloudData` +Run migration: +```bash +./scripts/migrate-data-directory.sh /media/rbhat/DATA/nextcloud/NextCloudData +``` +What the script does: +- enables maintenance mode +- copies current `/var/www/html/data` content to target directory +- applies owner/group and permissions for Nextcloud (`www-data`) +- updates `docker-compose.yml` app volume with `...:/var/www/html/data` +- recreates `app` and `web` services +- disables maintenance mode and verifies mount +Rollback (if needed): +1. Remove the `:/var/www/html/data` bind mount line from `app` volumes in `docker-compose.yml`. +2. `docker compose up -d app web` +3. Confirm status: +```bash +docker exec --user www-data nextcloud-app php occ status +``` + ## Production TLS (Let's Encrypt + Cloudflare DNS-01) 1. Export credentials in shell: ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 7dabfdc..511416f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,7 @@ services: - db volumes: - nextcloud_data:/var/www/html + - /media/rbhat/DATA/nextcloud/NextCloudData:/var/www/html/data networks: - nextcloud-net diff --git a/scripts/migrate-data-directory.sh b/scripts/migrate-data-directory.sh new file mode 100755 index 0000000..696fe9f --- /dev/null +++ b/scripts/migrate-data-directory.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# scripts/migrate-data-directory.sh +# Migrate Nextcloud data directory content to an external host path and +# update docker-compose app volume mapping to use that path. + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="${REPO_DIR}/docker-compose.yml" +APP_CONTAINER="${NEXTCLOUD_APP_CONTAINER:-nextcloud-app}" +TARGET_DATA_DIR="${1:-/media/rbhat/DATA/nextcloud/NextCloudData}" + +if [ "${TARGET_DATA_DIR}" = "-h" ] || [ "${TARGET_DATA_DIR}" = "--help" ]; then + echo "Usage: ./scripts/migrate-data-directory.sh [target_data_dir]" + echo "" + echo "Default target: /media/rbhat/DATA/nextcloud/NextCloudData" + exit 0 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "ERROR: docker is required but not installed." + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required but not installed." + exit 1 +fi + +if [ ! -f "${COMPOSE_FILE}" ]; then + echo "ERROR: Compose file not found at ${COMPOSE_FILE}" + exit 1 +fi + +if ! docker ps --format '{{.Names}}' | grep -qx "${APP_CONTAINER}"; then + echo "ERROR: Container '${APP_CONTAINER}' is not running." + echo "Start the stack first (docker compose up -d)." + exit 1 +fi + +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose -f "${COMPOSE_FILE}" "$@" + else + docker-compose -f "${COMPOSE_FILE}" "$@" + fi +} + +wait_for_occ() { + local max_attempts=30 + local attempt=1 + while [ "${attempt}" -le "${max_attempts}" ]; do + if docker exec --user www-data "${APP_CONTAINER}" php occ status >/dev/null 2>&1; then + return 0 + fi + sleep 2 + attempt=$((attempt + 1)) + done + return 1 +} + +if ! mkdir -p "${TARGET_DATA_DIR}" 2>/dev/null; then + if command -v sudo >/dev/null 2>&1; then + sudo mkdir -p "${TARGET_DATA_DIR}" + else + echo "ERROR: Unable to create ${TARGET_DATA_DIR}. Run as a user with write permission." + exit 1 + fi +fi + +CURRENT_BIND_SOURCE="$(docker inspect "${APP_CONTAINER}" --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/data"}}{{.Source}}{{end}}{{end}}')" + +maintenance_enabled=0 +cleanup() { + if [ "${maintenance_enabled}" -eq 1 ]; then + docker exec --user www-data "${APP_CONTAINER}" php occ maintenance:mode --off >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +echo "==> Enabling Nextcloud maintenance mode..." +docker exec --user www-data "${APP_CONTAINER}" php occ maintenance:mode --on >/dev/null +maintenance_enabled=1 + +if [ "${CURRENT_BIND_SOURCE}" = "${TARGET_DATA_DIR}" ]; then + echo "==> App already mounted to target data directory; skipping data copy." +else + echo "==> Copying data from running Nextcloud container to ${TARGET_DATA_DIR} ..." + docker exec "${APP_CONTAINER}" sh -lc 'cd /var/www/html/data && tar -cf - .' | \ + docker run --rm -i -v "${TARGET_DATA_DIR}:/to" alpine:3.20 sh -lc 'cd /to && tar -xf -' +fi + +echo "==> Normalizing ownership and permissions on target data directory..." +docker run --rm -v "${TARGET_DATA_DIR}:/to" alpine:3.20 sh -lc '\ + chown -R 33:33 /to && \ + find /to -type d -exec chmod 0750 {} + && \ + find /to -type f -exec chmod 0640 {} + && \ + touch /to/.ocdata && \ + chown 33:33 /to/.ocdata && \ + chmod 0640 /to/.ocdata\ +' + +echo "==> Updating compose app volume mapping to use external data directory..." +python3 - "${COMPOSE_FILE}" "${TARGET_DATA_DIR}" <<'PY' +from pathlib import Path +import sys + +compose_file = Path(sys.argv[1]) +target = sys.argv[2] +lines = compose_file.read_text().splitlines() + +try: + app_idx = next(i for i, line in enumerate(lines) if line.startswith(" app:")) +except StopIteration: + raise SystemExit("ERROR: Could not find 'app' service in compose file") + +app_end = len(lines) +for i in range(app_idx + 1, len(lines)): + line = lines[i] + if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): + app_end = i + break + +try: + volumes_idx = next(i for i in range(app_idx + 1, app_end) if lines[i].startswith(" volumes:")) +except StopIteration: + raise SystemExit("ERROR: Could not find volumes block in app service") + +items_start = volumes_idx + 1 +while items_start < app_end and lines[items_start].strip() == "": + items_start += 1 + +items_end = items_start +while items_end < app_end and lines[items_end].startswith(" - "): + items_end += 1 + +new_line = f" - {target}:/var/www/html/data" +updated = False +for i in range(items_start, items_end): + if lines[i].strip().endswith(":/var/www/html/data"): + if lines[i] != new_line: + lines[i] = new_line + updated = True + break + +if not updated: + lines.insert(items_end, new_line) + +compose_file.write_text("\n".join(lines) + "\n") +print("Updated compose file with app data bind mount") +PY + +echo "==> Recreating Nextcloud app/web services with new mount..." +compose up -d app web + +if ! wait_for_occ; then + echo "ERROR: Nextcloud did not become ready after restart." + exit 1 +fi + +echo "==> Disabling maintenance mode..." +docker exec --user www-data "${APP_CONTAINER}" php occ maintenance:mode --off >/dev/null +maintenance_enabled=0 + +echo "==> Verifying effective datadirectory and mount..." +docker exec --user www-data "${APP_CONTAINER}" php occ config:system:get datadirectory + +docker inspect "${APP_CONTAINER}" --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/data"}}{{println .Destination "<-" .Type ":" .Source}}{{end}}{{end}}' + +echo "==> Migration complete."