Use case · Backend platform

Appwrite self hosted on a dedicated server: install, secure, back up, upgrade

To run Appwrite self hosted, give a Linux server Docker Engine with the Compose v2 plugin, at least 2 cores, 4 GB of RAM and 2 GB of swap, then run the official installer image: its web wizard writes docker-compose.yml and .env, generates the encryption key, starts the stack behind Traefik on ports 80 and 443 and creates your console account. This page does that on one whole machine, with the wizard kept on loopback, and adds cold backups, a gated restore and the migration step every upgrade needs.

See the machines

What the Docker install runs

Appwrite is a backend platform: auth, databases, storage, functions, sites, messaging and realtime behind one API and a web console. The Docker install is a Compose project: by our count of the 2.6.2 file, 18 containers start in the default PostgreSQL layout with combined workers. Traefik 3.6 takes ports 80 and 443 and, since 2.3.0, 8883 and 8084 for the MQTT broker that delivers push notifications to devices. Behind it: the API, console, realtime server, worker, schedulers, Redis, ClickHouse for usage data and the database.

The 2.6.2 wizard offers PostgreSQL (the default), MariaDB or MongoDB. Functions and sites run through openruntimes-executor and an orchestrator, and both mount /var/run/docker.sock: they start runtime containers on the host Docker. Anything with that socket is root on the box, which is one reason this stack belongs on hardware nobody else shares.

Sizing the machine

ItemFigureSource and scope
CPU2 coresInstallation page, minimum
Memory4 GB RAM plus 2 GB swapInstallation page, minimum
ComposeDocker Compose version 2Installation page
Workers per core6 (default of _APP_WORKER_PER_CORE)Scaling page; affects the API, realtime and executor
Per-function limitsnone unless _APP_COMPUTE_CPUS / _APP_COMPUTE_MEMORY are setEnvironment variables page

Above that floor the load is yours to measure: every enabled runtime is an image on disk, every build and execution a container, and uploads land in the appwrite-uploads volume unless storage points at S3. Enable only the runtimes you use in _APP_FUNCTIONS_RUNTIMES and set the two compute limits so one runaway function cannot take every core. The migration command is multi-threaded, so cores also shorten upgrades.

Which of our machines fit

Any x86 line on the dedicated server range clears the minimum. A single-socket rack server is the plain choice for one production instance; a dual-socket machine (two Xeon E5 or two Gold 6130 chips) makes sense when functions and builds are busy or when staging runs beside production. Every racked single-, dual- and quad-socket server has an out-of-band console (iLO, iDRAC or a Supermicro BMC), which matters the first time a bad ufw rule locks out SSH. Of the Moonshot cartridges only the m510 and m710x have a console; the others have power control only.

Leave out the X-Gene ARM cartridge: it boots Ubuntu 16.04 and 18.04 only, and Docker's apt repository supports Ubuntu 22.04 and newer. Install Ubuntu 24.04 or 26.04 LTS from the control panel; the Linux server page covers the Linux installs. A racked server is handed to you in about 30 minutes, counted from payment approval; a build to order takes 4 to 24 hours. Unmetered ports have no transfer allowance, so storage downloads never become an overage line.

Installing with the wizard

  1. Put Docker Engine plus the Compose v2 plugin on the box with Docker's apt packages, as the docs.docker.com Ubuntu page shows. That page warns that published container ports bypass ufw.
  2. Point an A record for your Appwrite hostname at the server. Publish AAAA as well only when you took IPv6 at checkout (no default build includes it) and configured it, or Let's Encrypt may try a dead address.
  3. Connect with ssh -L 20080:127.0.0.1:20080 you@server, run sudo -i and paste this block. It halts unless Docker Engine reports version 28+, will not touch an existing install, opens SSH, 80 and 443 in ufw before Traefik asks for a certificate, adds 2 GB of swap if there is none, and starts the installer.
v=$(docker version --format '{{.Server.Version}}' 2>/dev/null)
if ! [ "${v%%.*}" -ge 28 ] 2>/dev/null || ! docker compose version >/dev/null 2>&1; then
  echo "STOP: needs Docker Engine 28+ and the Compose v2 plugin (found: ${v:-none})"
elif [ -e /opt/appwrite/.env ]; then
  echo "STOP: /opt/appwrite already holds an installation"
else
  sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw &&
  ufw allow OpenSSH && ufw --force enable && ufw allow 80,443/tcp &&
  { [ -n "$(swapon --noheadings)" ] || { fallocate -l 2G /swapfile && chmod 600 /swapfile &&
    mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' >> /etc/fstab; }; } &&
  cd /opt && docker run -it --rm \
    -e DO_NOT_TRACK=1 \
    --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:2.6.2 ||
  echo "STOP: a setup step (ufw, swap) or the installer failed; fix it and paste again"
fi

Our changes to the documented command, all of them: the wizard is published on 127.0.0.1:20080 instead of all interfaces (the docs say to open 20080 in the firewall; on Engine 28+ a loopback binding stays local, so the SSH tunnel replaces that), DO_NOT_TRACK=1 is set, and the run happens in /opt so the files land in /opt/appwrite. Keep the folder named appwrite: the upgrade guide warns that another name breaks the Docker project name.

The installer prints an address of the form http://localhost:20080/?secret=...; open that exact address, secret included, in your local browser (the tunnel carries it). Without the secret the wizard answers only with an installer-secret error. Step 1 takes the hostname, the HTTPS switch and the database; under Advanced settings set the certificate email to your own. Step 2 shows the generated secret key once: save it outside the server before going on. Step 3 sets your console email and password, and step 4 installs. The installer creates that account itself, after the stack is healthy.

Traefik publishes 80 and 443 on all addresses as soon as the stack starts, and the installer creates your account only once the API reports healthy, which on a first boot can take a minute or more. If the wizard reports that account creation failed, assume someone else signed up first. Wipe and clear the folder, since the block refuses to run over an existing .env: docker compose -f /opt/appwrite/docker-compose.yml down -v && mv /opt/appwrite /opt/appwrite.failed-$(date +%F-%H%M%S) (down -v deletes all Appwrite data, which is nothing yet), then paste the block again.

DO_NOT_TRACK=1 covers one thing: the report that Install.php sends to cloud.appwrite.io after a fresh production install (Report.php never sends one for an upgrade). It carries the version, domain, database, host IP, OS, architecture, CPU count and RAM, plus the admin name and email unless the hostname is localhost or 127.x. It changes nothing in the running server.

Before anyone else uses it

  • Run chmod 600 /opt/appwrite/.env. It holds _APP_OPENSSL_KEY_V1; the security page warns that changing that key loses existing passwords, OAuth secrets and API keys.
  • Only the first user can register on the console; the rest need an invitation (_APP_CONSOLE_WHITELIST_ROOT, enabled by default). The console sets up the instance's one organization for you, and teammates join it by invitation.
  • Appwrite issues a Let's Encrypt certificate for _APP_DOMAIN on the first visit, using the HTTP challenge on port 80, and renews in the maintenance task (every 24 hours by default). Function domains need a wildcard via a DNS challenge, or docker compose exec appwrite ssl per domain.
  • If you do not send push over MQTT, set _APP_MQTT_PORT=127.0.0.1:8883 and _APP_MQTT_WSS_PORT=127.0.0.1:8084 in .env (our change; the variables name host ports) so those two stay local. Docker-published ports skip ufw on IPv4 (and on IPv6 when Docker's own IPv6 networking is on), so ufw alone does not close them.
  • Apply any .env edit with docker compose -f /opt/appwrite/docker-compose.yml up -d --force-recreate and check it with ... exec appwrite vars. Configure SMTP too, or verification and recovery mail goes nowhere.

Backups and a restore you can trust

The backup page lists the database, the storage volumes and .env, and says to shut Appwrite down before copying volumes. It gives dump commands for MariaDB and MongoDB but none yet for the PostgreSQL default, so this script, ours, copies every volume with appwrite in its name (database, uploads, functions, sites, builds, certificates, config, ClickHouse, Redis) plus the whole /opt/appwrite folder, with the stack stopped for the minutes it takes. As root:

mkdir -m 700 -p /var/backups/appwrite /var/log/appwrite-backup
cat > /root/appwrite-backup.sh <<'EOF'
umask 077; D=/opt/appwrite; C="docker compose -f $D/docker-compose.yml"
B=/var/backups/appwrite/$(date +%F-%H%M%S); mkdir -p "$B" || exit 1
vols=$(docker volume ls -q --filter name=appwrite)
[ -n "$vols" ] || { echo "no appwrite volumes found"; exit 1; }
$C stop || exit 1
ok=1; tar -czf "$B/config.tar.gz" -C /opt appwrite || ok=0
for v in $vols; do
  docker run --rm -v "$v":/data:ro -v "$B":/backup ubuntu \
    tar -czf "/backup/$v.tar.gz" -C /data . || ok=0
done
$C start
for f in "$B"/*.tar.gz; do tar -tzf "$f" >/dev/null || ok=0; done
[ $ok = 1 ] || { echo "backup FAILED, see $B"; exit 1; }
echo "$vols" > "$B/volumes.txt" && touch "$B/COMPLETE" && echo "backup ok: $B"
[ -e "$B/COMPLETE" ] && [ -z "$NOPRUNE" ] && find /var/backups/appwrite -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} +
EOF
printf '%s\n' '30 3 * * * root bash /root/appwrite-backup.sh > /var/log/appwrite-backup/last.log 2>&1' > /etc/cron.d/appwrite-backup

Each set is a folder named to the second, marked COMPLETE only when every archive passed tar -tzf; week-old sets are pruned only after a good run. Copy /var/backups/appwrite to another machine yourself using rsync -a without --delete, and expire old sets on that side. The archives contain the encryption key: store them like a password.

To restore, as root, set S to a set folder and paste the block. On the same box it first takes a fresh safety backup and stops if that fails, then moves the current /opt/appwrite aside under a timestamped name, so a rollback never trips over an earlier one. On a fresh box (Docker and ufw done as above, set copied into /var/backups/appwrite) it restores directly. Config comes back owned by root with .env at 600; volume files keep their numeric owners.

S=/var/backups/appwrite/CHANGE-ME; D=/opt/appwrite; ok=0
[ -e "$S/COMPLETE" ] && ( for f in "$S"/*.tar.gz; do tar -tzf "$f" >/dev/null || exit 1; done ) && ok=1
if [ $ok = 1 ] && [ -e "$D/.env" ]; then
  ok=0; NOPRUNE=1 bash /root/appwrite-backup.sh | tee /root/pre-restore.log | grep -q '^backup ok:' &&
  docker compose -f "$D/docker-compose.yml" down &&
  mv "$D" "$D.previous-$(date +%F-%H%M%S)" && ok=1
fi
if [ $ok = 1 ] && [ ! -e "$D" ]; then
  [ -e "$S/COMPLETE" ] && tar -xzpf "$S/config.tar.gz" -C /opt && chown -R root:root "$D" && chmod 600 "$D/.env" &&
  docker compose -f "$D/docker-compose.yml" up --no-start &&
  ( for v in $(cat "$S/volumes.txt"); do docker volume create "$v" >/dev/null &&
      docker run --rm -e V="$v" -v "$v":/data -v "$S":/backup:ro ubuntu bash -c \
      'find /data -mindepth 1 -delete && tar -xzpf "/backup/$V.tar.gz" -C /data' || exit 1
    done ) &&
  docker compose -f "$D/docker-compose.yml" up -d && echo "restore ok" ||
  echo "RESTORE FAILED part-way: roll back with the set in /root/pre-restore.log"
else
  echo "STOP: nothing restored (bad set, failed safety backup, or $D in the way)"
fi

To roll back, run the same block with S set to the safety set named in /root/pre-restore.log. If a restore died after unpacking the config, move the half-restored /opt/appwrite aside by hand first.

Upgrades without skipping migrations

  1. Read the release notes of the target and of every release you skip; 2.6.2, for example, now refuses an empty or placeholder _APP_JOBS_SECRET.
  2. Back up and stop on failure: bash /root/appwrite-backup.sh | grep '^backup ok:' && echo go.
  3. With the tunnel open, from /opt, run the install block's docker run line with --entrypoint="upgrade" and the new tag (never latest), open the ?secret= address it prints, and finish the wizard.
  4. Check docker ps | grep appwrite/appwrite shows one version everywhere, then run docker compose -f /opt/appwrite/docker-compose.yml exec appwrite migrate.

Patch releases skip the migration unless their notes ask for it. 2.6.2 upgrades from 1.9.6 or later in one go; 1.8.x goes 1.9.5, then 1.9.10, then 2.6.2. Rehearse on a copy built with the restore block on a second machine.

Where this is the wrong setup

Appwrite's own docs recommend their managed cloud to teams without DevOps experience and warn that self-hosting often costs more once time is counted. One Compose host is one failure domain: for replicas, Appwrite ships a Helm chart, and Kubernetes on bare metal is where that runs. If you want a Postgres-first stack with SQL at the centre, compare self-hosted Supabase. Appwrite is BSD 3-Clause; our general reading, not legal advice: redistributing it means keeping the copyright notice and licence text, with no duty to publish your source.

Questions

What hardware does a self-hosted Appwrite need?

The installation page sets the minimum at 2 CPU cores, 4 GB of RAM and 2 GB of swap on a system that runs Docker with Compose v2. Functions, builds and stored files add CPU, memory and disk on top of that.

Which database does the installer use now?

Appwrite 2.6.2's wizard selects PostgreSQL by default and also offers MariaDB and MongoDB. The volume backup on this page works for all three, because it copies the database files with the stack stopped.

Can I change the encryption key later?

Not safely. _APP_OPENSSL_KEY_V1 encrypts files and secrets, and the security page warns that changing it loses existing passwords, OAuth secrets and API keys. Keep the key the wizard showed you, and restore .env with the data so the two always match.

What does an Appwrite version upgrade involve?

Back up, run the installer image with the upgrade entrypoint and the exact new tag, confirm every Appwrite container reports it, then run migrate. Releases older than 1.9.6 step through the versions the upgrade guide lists.

Do I need my own reverse proxy?

No. Traefik in the stack terminates TLS on 443 with Let's Encrypt certificates for your main domain. Use your own proxy only if 80 and 443 are already taken; the TLS page notes the HTTP challenge forces certificates onto those two ports.

Put the backend on a machine of its own

Whole servers with root, an unmetered port and an out-of-band console on every rack server.

See the machines

Related on Server Room

Other use cases