> ## Documentation Index
> Fetch the complete documentation index at: https://docs.arct.cloud/llms.txt
> Use this file to discover all available pages before exploring further.

# Self-host Appwrite on a VPS

> Deploy Appwrite on an Ubuntu VPS with its official Docker installer, private account setup, HTTPS, backups, and safe migrations.

Arct Cloud provides the unmanaged Linux VPS for this deployment. Appwrite is not preinstalled or managed by Arct Cloud. You are responsible for application configuration, data, functions, email delivery, security, backups, scaling, and updates.

## Choose a Plan

Appwrite's [official installation requirements](https://appwrite.io/docs/advanced/self-hosting/installation) specify at least 2 CPU cores, 4 GB RAM, 2 GB swap, Docker Compose v2, and a current Docker Engine.

| Profile       | vCPU |  RAM | NVMe storage | Guidance                                                                                                        |
| ------------- | ---: | ---: | -----------: | --------------------------------------------------------------------------------------------------------------- |
| **cvm.micro** |    2 | 4 GB |        40 GB | Meets the official CPU and RAM minimum; suitable for evaluation and light development after adding 2 GB swap    |
| **cvm.small** |    4 | 8 GB |        75 GB | Practical Arct starting point with more headroom for workers, builds, functions, databases, and image downloads |

Appwrite does not publish a universal disk minimum. It runs many containers and stores database records, uploads, function code, site builds, certificates, and logs. Monitor memory and disk usage, then increase resources or use an external storage backend as the workload grows. These are practical starting points, not performance guarantees.

## Before You Begin

Prepare the following:

* A fresh Ubuntu 24.04 server
* A dedicated hostname such as `appwrite.example.com`
* An `A` record pointing the hostname to the VPS's public IPv4 address
* An email address for TLS certificate notifications
* Encrypted off-server storage for configuration, database, and volume backups
* An authenticated SMTP provider if users need verification or password recovery email

This guide uses Appwrite's current stable `1.9.6` release and the installer's default MongoDB backend. Check the [Appwrite releases](https://github.com/appwrite/appwrite/releases) before installation and replace the version only after reviewing its release notes.

## Install Appwrite

<Steps>
  <Step title="Deploy and Connect">
    [Deploy an Ubuntu server](/compute/virtual-machines/deploy), then [connect over SSH](/compute/virtual-machines/connect-ssh).
  </Step>

  <Step title="Prepare DNS and the Firewall">
    Confirm the hostname resolves to this server:

    ```bash theme={null}
    getent ahostsv4 appwrite.example.com
    ```

    Allow SSH before enabling UFW, then expose only the public web ports:

    ```bash theme={null}
    sudo ufw allow OpenSSH
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw enable
    sudo ufw status
    ```

    Port `20080` is used only by Appwrite's installation wizard. The command below binds it to server loopback, so do not open it in UFW or an upstream firewall.
  </Step>

  <Step title="Install Docker Engine and Compose">
    Install Docker from Docker's official Ubuntu repository:

    ```bash theme={null}
    sudo apt update
    sudo apt install -y ca-certificates curl
    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
      -o /etc/apt/keyrings/docker.asc
    sudo chmod a+r /etc/apt/keyrings/docker.asc

    sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
    Types: deb
    URIs: https://download.docker.com/linux/ubuntu
    Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
    Components: stable
    Architectures: $(dpkg --print-architecture)
    Signed-By: /etc/apt/keyrings/docker.asc
    EOF

    sudo apt update
    sudo apt install -y docker-ce docker-ce-cli containerd.io \
      docker-buildx-plugin docker-compose-plugin
    sudo systemctl enable --now docker
    sudo docker run --rm hello-world
    sudo docker compose version
    ```
  </Step>

  <Step title="Verify Memory and Add Swap">
    Check that the server has at least 4 GB RAM and 2 GB swap:

    ```bash theme={null}
    free -h
    swapon --show
    ```

    On a fresh server with no swap, create the 2 GB swap file required by Appwrite:

    ```bash theme={null}
    sudo fallocate -l 2G /swapfile
    sudo chmod 600 /swapfile
    sudo mkswap /swapfile
    sudo swapon /swapfile
    grep -qF '/swapfile none swap sw 0 0' /etc/fstab \
      || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
    swapon --show
    ```

    <Warning>Run the swap creation commands only when `swapon --show` is empty and `/swapfile` does not already exist.</Warning>
  </Step>

  <Step title="Start the Installer Privately">
    Create the parent directory, then run Appwrite's official installer with two deliberate safeguards: an exact image tag and a loopback-only wizard port.

    ```bash theme={null}
    sudo install -d -m 0750 -o "$USER" -g "$USER" /opt/appwrite
    cd /opt

    sudo docker run -it --rm \
      --publish 127.0.0.1:20080:20080 \
      --volume /var/run/docker.sock:/var/run/docker.sock \
      --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
      --entrypoint="install" \
      appwrite/appwrite:1.9.6
    ```

    Leave this SSH session open while the installer waits for the web wizard.

    <Warning>The installer controls Docker through `/var/run/docker.sock`, which is equivalent to root access on the host. Use only Appwrite's official image with a reviewed release tag.</Warning>
  </Step>

  <Step title="Complete the Private Setup Wizard">
    From a second terminal on your local computer, open an SSH tunnel:

    ```bash theme={null}
    ssh -N -L 20080:127.0.0.1:20080 ubuntu@YOUR_SERVER_IP
    ```

    Open `http://127.0.0.1:20080` and complete the wizard:

    1. Enter `appwrite.example.com` as the hostname.
    2. Keep MongoDB selected unless you have planned a MariaDB deployment and backup strategy.
    3. Configure automatic HTTPS and its notification email.
    4. Generate the application encryption key and save it outside the VPS.
    5. Create the root console account with a unique password.
    6. Review the settings, then start the installation.

    The setup wizard remains private for the entire first-account flow. Stop the tunnel with `Ctrl+C` only after the installer finishes and the root account exists.
  </Step>

  <Step title="Rotate Default Service Secrets">
    Appwrite's [database security guidance](https://appwrite.io/docs/advanced/self-hosting/configuration/databases) requires changing the database credentials before production. The `1.9.6` wizard sets the application encryption key, but its generated configuration retains known defaults for `_APP_DB_PASS`, `_APP_DB_ROOT_PASS`, and `_APP_EXECUTOR_SECRET`. Rotate all three immediately after setup and before creating projects or accepting production data.

    The following MongoDB-specific maintenance flow creates a root-only recovery set, transfers new values without printing them or placing them in host-side command arguments, changes the database credentials, atomically updates `.env`, and recreates every service with matching values:

    ```bash theme={null}
    (
      set -euo pipefail
      cd /opt/appwrite
      sudo chmod 0600 .env

      pre_rotate_dir="/root/appwrite-pre-rotation-$(date -u +%Y%m%dT%H%M%SZ)"
      sudo install -d -m 0700 "$pre_rotate_dir"
      sudo cp -p .env docker-compose.yml "$pre_rotate_dir/"

      rotation_dir="$(sudo mktemp -d /root/appwrite-rotation.XXXXXX)"
      candidate_env="$(sudo mktemp /opt/appwrite/.env.rotate.XXXXXX)"
      rotation_finished=false
      report_rotation_state() {
        if [ "$rotation_finished" = false ]; then
          printf 'Rotation stopped. Keep recovery data in %s, %s, and %s if present.\n' \
            "$pre_rotate_dir" "$rotation_dir" "$candidate_env" >&2
        fi
      }
      trap report_rotation_state EXIT

      sudo sh -c '
        set -eu
        umask 077
        openssl rand -hex 32 >"$1/db-pass"
        openssl rand -hex 32 >"$1/db-root-pass"
        openssl rand -hex 32 >"$1/executor-secret"
      ' sh "$rotation_dir"

      sudo bash -s -- "$rotation_dir" "$candidate_env" <<'BUILD_ENV'
    set -euo pipefail
    secret_dir="$1"
    candidate="$2"
    env_file="/opt/appwrite/.env"

    db_pass="$(<"$secret_dir/db-pass")"
    db_root_pass="$(<"$secret_dir/db-root-pass")"
    executor_secret="$(<"$secret_dir/executor-secret")"
    seen_db=0
    seen_root=0
    seen_executor=0

    while IFS= read -r line || [[ -n "$line" ]]; do
      case "$line" in
        _APP_DB_PASS=*)
          printf '_APP_DB_PASS="%s"\n' "$db_pass"
          ((seen_db += 1))
          ;;
        _APP_DB_ROOT_PASS=*)
          printf '_APP_DB_ROOT_PASS="%s"\n' "$db_root_pass"
          ((seen_root += 1))
          ;;
        _APP_EXECUTOR_SECRET=*)
          printf '_APP_EXECUTOR_SECRET="%s"\n' "$executor_secret"
          ((seen_executor += 1))
          ;;
        *) printf '%s\n' "$line" ;;
      esac
    done <"$env_file" >"$candidate"

    if (( seen_db != 1 || seen_root != 1 || seen_executor != 1 )); then
      echo 'Expected each Appwrite secret exactly once; rotation was not started.' >&2
      exit 1
    fi

    chown root:root "$candidate"
    chmod 0600 "$candidate"
    BUILD_ENV

      sudo docker compose stop
      sudo docker compose up -d --wait mongodb
      sudo docker compose exec -T mongodb sh -c \
        'exec mongodump --username=root --password="$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase=admin --archive' \
        | sudo tee "$pre_rotate_dir/mongodb.archive" >/dev/null
      sudo docker compose cp \
        "$rotation_dir/db-pass" mongodb:/tmp/appwrite-new-db-pass
      sudo docker compose cp \
        "$rotation_dir/db-root-pass" mongodb:/tmp/appwrite-new-db-root-pass

      sudo docker compose exec -T mongodb bash -se <<'ROTATE_MONGO'
    set -euo pipefail
    export NEW_DB_PASS="$(cat /tmp/appwrite-new-db-pass)"
    export NEW_ROOT_PASS="$(cat /tmp/appwrite-new-db-root-pass)"

    mongosh --quiet \
      --username "$MONGO_INITDB_ROOT_USERNAME" \
      --password "$MONGO_INITDB_ROOT_PASSWORD" \
      --authenticationDatabase admin \
      --eval '
        const admin = db.getSiblingDB("admin");
        admin.changeUserPassword(
          process.env.MONGO_INITDB_USERNAME,
          process.env.NEW_DB_PASS
        );
        admin.changeUserPassword(
          process.env.MONGO_INITDB_ROOT_USERNAME,
          process.env.NEW_ROOT_PASS
        );
      '

    rm -f /tmp/appwrite-new-db-pass /tmp/appwrite-new-db-root-pass
    ROTATE_MONGO

      sudo mv -- "$candidate_env" /opt/appwrite/.env
      sudo docker compose up -d --force-recreate --wait
      sudo docker compose exec -T mongodb bash -se <<'VERIFY_MONGO'
    mongosh --quiet \
      --username "$MONGO_INITDB_USERNAME" \
      --password "$MONGO_INITDB_PASSWORD" \
      --authenticationDatabase admin \
      --eval 'if (db.getSiblingDB(process.env.MONGO_INITDB_DATABASE).runCommand({ ping: 1 }).ok !== 1) { quit(1); }' \
      >/dev/null
    VERIFY_MONGO

      sudo docker compose exec -T appwrite sh -ec '
        printf "header = \"Authorization: Bearer %s\"\n" \
          "$_APP_EXECUTOR_SECRET" \
          | curl --config - --fail --silent --show-error \
              "${_APP_EXECUTOR_HOST}/health" >/dev/null
      '
      sudo docker exec appwrite doctor
      sudo docker compose ps

      sudo bash <<'CHECK_SECRETS'
    set -euo pipefail
    declare -A secret=()

    while IFS='=' read -r key raw; do
      case "$key" in
        _APP_OPENSSL_KEY_V1|_APP_DB_PASS|_APP_DB_ROOT_PASS|_APP_EXECUTOR_SECRET)
          raw="${raw#\"}"
          raw="${raw%\"}"
          secret["$key"]="$raw"
          ;;
      esac
    done </opt/appwrite/.env

    bad=0
    for key in \
      _APP_OPENSSL_KEY_V1 \
      _APP_DB_PASS \
      _APP_DB_ROOT_PASS \
      _APP_EXECUTOR_SECRET
    do
      current="${secret[$key]-}"
      if (( ${#current} < 32 )); then
        printf '%s is missing or shorter than 32 characters.\n' "$key" >&2
        bad=1
      fi
      case "$current" in
        your-secret-key|password|rootsecretpassword)
          printf '%s still uses an Appwrite default.\n' "$key" >&2
          bad=1
          ;;
      esac
    done

    if [[ "${secret[_APP_DB_PASS]-}" == "${secret[_APP_DB_ROOT_PASS]-}" ||
          "${secret[_APP_DB_PASS]-}" == "${secret[_APP_EXECUTOR_SECRET]-}" ||
          "${secret[_APP_DB_ROOT_PASS]-}" == "${secret[_APP_EXECUTOR_SECRET]-}" ]]; then
      echo 'Appwrite secrets must be distinct.' >&2
      bad=1
    fi

    (( bad == 0 ))
    echo 'Required Appwrite secrets are non-default, sufficiently long, and distinct.'
    CHECK_SECRETS

      sudo rm -f -- \
        "$rotation_dir/db-pass" \
        "$rotation_dir/db-root-pass" \
        "$rotation_dir/executor-secret"
      sudo rmdir -- "$rotation_dir"
      rotation_finished=true
      trap - EXIT
      printf 'Pre-rotation recovery set: %s\n' "$pre_rotate_dir"
    )
    ```

    <Warning>This sequence is only for the MongoDB deployment in this guide. If it stops after changing MongoDB, do not restart the full stack or delete `/root/appwrite-rotation.*` or `/opt/appwrite/.env.rotate.*`. Keep the root-only recovery files, determine which credential changes completed, then finish installing the candidate `.env` or restore on a fresh instance.</Warning>

    Copy `.env` and the pre-rotation recovery set to encrypted off-server storage. Never regenerate `_APP_OPENSSL_KEY_V1` on an existing instance: Appwrite uses it to protect passwords, OAuth secrets, API keys, and other sensitive values.
  </Step>

  <Step title="Verify the Deployment">
    Confirm the containers are healthy, then make the first HTTPS request. Appwrite can initially present a self-signed certificate; the first request triggers certificate issuance. Use `--insecure` only for that initial probe, wait for the certificate to be issued in the logs, and require the final check to pass normal TLS validation:

    ```bash theme={null}
    cd /opt/appwrite
    sudo docker compose ps
    curl --insecure --head https://appwrite.example.com
    sudo docker compose logs --tail=50 appwrite traefik mongodb redis
    curl --fail --show-error --head https://appwrite.example.com
    sudo docker exec appwrite doctor
    ```

    Sign in with the root account created in the private wizard, create a test project, and verify that its API endpoint uses `https://appwrite.example.com/v1`.
  </Step>
</Steps>

## Production Security and Operations

Only ports `22`, `80`, and `443` should normally be public. Port `20080` is temporary and loopback-only. Database, Redis, worker, executor, and Docker API ports must remain private.

Before storing production data:

* Keep `_APP_OPENSSL_KEY_V1` unchanged and backed up separately from the database
* Require the default-secret rotation and non-printing validation above to pass
* Force HTTPS and confirm no application SDK uses the plain HTTP endpoint
* Keep console registration restricted to the root user and invite additional developers
* Use Appwrite's console IP or email allowlists when the dashboard needs tighter access
* Keep Appwrite's abuse protection and rate limits enabled
* Configure authenticated SMTP and test verification and password-reset delivery
* Use least-privilege API keys and review function permissions and runtime limits
* Monitor CPU, RAM, swap, disk, container health, and log growth

Docker-published ports can bypass ordinary UFW forwarding rules. The wizard port is safe here because it is explicitly bound to `127.0.0.1`; review every future Compose `ports` change before applying it.

## Back Up and Restore

Self-hosted Appwrite does not provide automatic backups. A complete recovery set includes the database, Appwrite storage volumes, `.env`, `docker-compose.yml`, the encryption key, and the exact image versions.

For the MongoDB deployment used in this guide, the following maintenance-window example records the configuration, stops Appwrite, creates an official logical database dump, and archives every non-database Compose-managed volume:

```bash theme={null}
(
  set -euo pipefail
  cd /opt/appwrite

  stamp="$(date -u +%Y%m%dT%H%M%SZ)"
  backup_dir="/opt/appwrite-backups/${stamp}"
  sudo install -d -m 0700 -o "$USER" -g "$USER" "$backup_dir"

  sudo cp -p .env docker-compose.yml "$backup_dir/"
  sudo chown "$USER:$USER" "$backup_dir/.env" \
    "$backup_dir/docker-compose.yml"
  sudo docker compose config --images >"$backup_dir/images.txt"
  sudo docker compose config --volumes >"$backup_dir/volumes.txt"
  mapfile -t appwrite_volumes < <(
    sudo docker volume ls -q \
      --filter label=com.docker.compose.project=appwrite
  )
  test "${#appwrite_volumes[@]}" -gt 0
  sudo docker pull ubuntu:24.04

  stopped=false
  restart_appwrite() {
    if [ "$stopped" = true ]; then
      sudo docker compose up -d || true
    fi
  }
  trap restart_appwrite EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM

  stopped=true
  sudo docker compose down

  sudo docker compose up -d --wait mongodb
  sudo docker compose exec -T mongodb sh -c \
    'exec mongodump --username=root --password="$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase=admin --archive' \
    >"$backup_dir/mongodb.archive"
  sudo docker compose stop mongodb

  for volume in "${appwrite_volumes[@]}"; do
    case "$volume" in
      *mongodb*|*mariadb*|*postgresql*) continue ;;
    esac
    sudo docker run --rm \
      --volume "${volume}:/data:ro" \
      --volume "${backup_dir}:/backup" \
      ubuntu:24.04 \
      tar -czf "/backup/${volume}.tar.gz" -C /data .
  done

  sudo docker compose up -d
  stopped=false
  trap - EXIT INT TERM

  sudo chown -R "$USER:$USER" "$backup_dir"
  chmod -R go-rwx "$backup_dir"
  printf 'Appwrite backup ready: %s\n' "$backup_dir"
)
```

Copy the entire timestamped directory to encrypted storage outside the VPS and test it on a separate server. If you select MariaDB in the installer, use Appwrite's documented `mysqldump` procedure instead of the MongoDB command.

Restore only into a fresh installation running the matching Appwrite and database versions. For this MongoDB path, restore `mongodb.archive` into the fresh database, then restore the non-database storage archives with the saved configuration and encryption key. Do not also restore a raw MongoDB data volume: logical database restore and raw database-volume restore are alternative recovery methods. Never restore over a live database or delete the current volumes until the replacement instance has passed login, API, upload, function, and project checks.

## Update and Migrate Safely

Appwrite updates can change both its Compose project and data schema. Before every update:

1. Create and verify a complete off-server backup.
2. Read every intervening [release note](https://github.com/appwrite/appwrite/releases).
3. Record the current image tags and keep the existing VPS available.
4. Test the same upgrade path on a replacement instance.

Run the official upgrade tool from the parent directory, replacing the example variable with an exact release tag:

```bash theme={null}
cd /opt
APPWRITE_VERSION=REPLACE_WITH_EXACT_RELEASE_TAG

sudo docker run -it --rm \
  --publish 127.0.0.1:20080:20080 \
  --volume /var/run/docker.sock:/var/run/docker.sock \
  --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
  --entrypoint="upgrade" \
  "appwrite/appwrite:${APPWRITE_VERSION}"
```

Leave that command running while it waits for the private upgrade wizard. From a second terminal on your local computer, recreate the SSH tunnel:

```bash theme={null}
ssh -N -L 20080:127.0.0.1:20080 ubuntu@YOUR_SERVER_IP
```

Open `http://127.0.0.1:20080`, review the detected deployment and exact target release, then complete the upgrade wizard. Close the tunnel only after the upgrade command finishes.

Patch releases do not always require a data migration. Run the migration command only when the release notes require it:

```bash theme={null}
cd /opt/appwrite
sudo docker compose exec appwrite migrate
sudo docker compose ps
sudo docker compose logs --tail=100 appwrite
```

When crossing minor versions, Appwrite requires upgrading through each minor version's latest patch. Do not start an older Appwrite image against data that a newer migration has changed. If an update fails, restore the complete pre-update recovery set on a fresh matching-version server instead of attempting an in-place downgrade.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The installation wizard does not open">
    Keep the installer terminal running, confirm `sudo ss -lntp | grep ':20080'` shows a loopback listener, and reconnect the SSH tunnel. Do not change the mapping to `0.0.0.0:20080` as a shortcut.
  </Accordion>

  <Accordion title="HTTPS certificate issuance fails">
    Confirm the `A` record points to this VPS, remove an incorrect `AAAA` record, and verify ports `80` and `443` are reachable. Review the Traefik and Appwrite logs for ACME or hostname errors.
  </Accordion>

  <Accordion title="Containers restart or the server becomes unresponsive">
    Run `free -h`, `swapon --show`, `df -h`, and `sudo docker stats`. Appwrite's many workers and build images can exceed the minimum profile; add memory or storage before increasing worker concurrency.
  </Accordion>

  <Accordion title="Users do not receive verification or reset email">
    Configure an authenticated SMTP provider, normally on submission port `587`, then test delivery and inspect the worker logs. Do not rely on unauthenticated local mail delivery.
  </Accordion>

  <Accordion title="An update or migration fails">
    Stop retrying against production data. Inspect the first migration error and the exact release notes, then recover on a separate server from the pre-update database, volumes, configuration, and encryption key.
  </Accordion>
</AccordionGroup>

## Official Resources

<CardGroup cols={3}>
  <Card title="Appwrite Installation" icon="book-open" href="https://appwrite.io/docs/advanced/self-hosting/installation">
    Official requirements, Docker installer, setup wizard, and manual Compose files.
  </Card>

  <Card title="Production Checklist" icon="shield-halved" href="https://appwrite.io/docs/advanced/self-hosting/production">
    Security, scaling, email, monitoring, backups, and update guidance.
  </Card>

  <Card title="Appwrite on GitHub" icon="github" href="https://github.com/appwrite/appwrite">
    Source code, releases, security policy, and issue tracker.
  </Card>
</CardGroup>

<Note>Appwrite is developed by Appwrite. Arct Cloud is an independent infrastructure provider and is not affiliated with, sponsored by, or endorsed by Appwrite.</Note>
