# Can I self-host Retool?

**YES** — it's called Appsmith. ONE EVENING setup · ~1.5 hours to running · 8 GB RAM minimum · $60/mo you stop paying ($720/yr on the Team plan, 5 seats assumed).

Appsmith authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/retool/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Appsmith v2.2 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until
they answer. `<DOMAIN>` is the hostname whose A record already points at this server.
`<ADMIN_EMAIL>` is the address that will own the instance, and it matters more here than in
most installs: this prompt closes signup before the first boot and names that one address as
the exception, so it is the only address that can create an account afterwards. Take it in
lowercase and repeat it back to the user, character for character.

Appsmith needs 8192 MB of RAM available and 20 GB free on /srv. That floor is not padding.
The image is one container that runs the application, MongoDB, Redis and PostgreSQL together
under supervisord, and upstream's own baseline for a self-hosted deployment is 2 vCPU and 8 GB.
The image publishes amd64 and arm64. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 8192 MB or free disk is under 20 GB, print both numbers and stop. Do
not install and hope: the OOM killer arrives partway through the first boot and the failure
looks random. If `dig +short` prints nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/appsmith /srv/appsmith/backups
sudo install -d -m 755 /srv/appsmith/stacks
ls -la /srv/appsmith
```

Assert: `ls -la` shows `backups` owned by the login user and `stacks` owned by root. Leave
`stacks` to root. The container starts as root and hands its embedded PostgreSQL a data
directory it chowns to its own uid, so an ownership fix applied from outside is undone on the
first boot and gets in the way of the restore in step 8. Everything the instance keeps, from
the MongoDB files to the git checkouts to the docker.env it writes for itself, lands under
that one directory.

## 3. Secrets

Two secrets: the encryption password and the encryption salt. Upstream generates a pair of
13-character values inside the container if none arrive from outside, and values set from
outside win, so generate them here where the user can read them back and keep them. They are
what encrypt every database password, API key and OAuth token the user later hands to a
datasource. Do not print either, do not repeat them in your summary, and do not put them in
any log line.

```bash
umask 077
cat > /srv/appsmith/.env <<EOF
APPSMITH_ADMIN_EMAILS=<ADMIN_EMAIL>
APPSMITH_SIGNUP_DISABLED=true
APPSMITH_BASE_URL=https://<DOMAIN>
APPSMITH_ENCRYPTION_PASSWORD=$(openssl rand -hex 32)
APPSMITH_ENCRYPTION_SALT=$(openssl rand -hex 32)
EOF
chmod 600 /srv/appsmith/.env
umask 022
ls -l /srv/appsmith/.env
```

Assert: the file exists with mode `-rw-------`. Tell the user two things. First, those two
values are readable with
`sudo grep -E 'ENCRYPTION_PASSWORD|ENCRYPTION_SALT' /srv/appsmith/.env` and belong in their
password manager tonight, because a data directory restored without them comes back with every
app intact and every datasource unable to decrypt its own credentials. Second,
`APPSMITH_SIGNUP_DISABLED` is read once, during the first boot, and written into the database;
after that the setting lives there and changes in Admin Settings, not in this file.

## 4. compose.yml

```bash
cat > /srv/appsmith/compose.yml <<'EOF'
# Appsmith · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.appsmith.com/getting-started/setup/installation-guides/docker
#   variable reference . https://docs.appsmith.com/getting-started/setup/environment-variables
#   capacity planning .. https://docs.appsmith.com/getting-started/setup/infrastructure-sizing
#   backup and restore . https://docs.appsmith.com/getting-started/setup/instance-management/appsmithctl
#
# One service, and it is a heavy one. Upstream ships an all-in-one image that
# runs MongoDB, Redis and PostgreSQL inside this same container under
# supervisord, which is why no database service appears below and why the RAM
# floor is 8 GB rather than the few hundred megabytes a web app alone would
# want. Everything the instance keeps, including the docker.env it generates
# for itself on first boot, lives under /appsmith-stacks.
#
# APPSMITH_CUSTOM_DOMAIN is deliberately never set here: setting it makes the
# container ask Let's Encrypt for a certificate of its own, and the host's
# Caddy already terminates TLS. The container's 443 is therefore never
# published and only its plain-http 80 is. Tag and digest were read from the
# registry on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  appsmith:
    image: appsmith/appsmith-ce:v2.2@sha256:dc17b968c88eebf42b85c2e22b97efb55f2339b2d685e48f804c5f87bdd9d4e5
    container_name: appsmith
    restart: unless-stopped
    env_file: /srv/appsmith/.env
    environment:
      # Upstream ships anonymous usage collection turned on. This turns it off.
      APPSMITH_DISABLE_TELEMETRY: "true"
      # The docker.env the container writes for itself lets any site on the
      # internet load these apps in an iframe. This narrows the
      # Content-Security-Policy back to this hostname and nothing else.
      APPSMITH_ALLOWED_FRAME_ANCESTORS: "'self'"
    volumes:
      - /srv/appsmith/stacks:/appsmith-stacks
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8143.
      - "127.0.0.1:8143:80"
EOF
cd /srv/appsmith && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind mount.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-appsmith
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Appsmith · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.appsmith.com/getting-started/setup/installation-guides/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APPSMITH_BASE_URL in .env, which is the value Appsmith compares the
# Origin header of a password-reset request against, so the two have to agree.

<DOMAIN> {
	# The container runs a Caddy of its own and already sends
	# Content-Security-Policy and X-Content-Type-Options on every response, so
	# this block does not restate them and cannot contradict them. HSTS is
	# here because nothing inside the container knows it is being served over
	# https. There is no `encode` either: the inner Caddy compresses already,
	# and compressing a second time costs CPU for no bytes.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8143 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. The editor holds a
	# WebSocket open to /rts, and reverse_proxy carries that upgrade with no
	# extra configuration.
	reverse_proxy 127.0.0.1:8143
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-appsmith, reload, and report what it objected to. Caddy requests
the certificate on the first request and renews it on its own, so there is nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8143 stays closed because it is bound to 127.0.0.1. The MongoDB, Redis and
PostgreSQL inside the container listen on the container's own loopback and are never published
at all, so there is no host port for them to firewall. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no rule for 8143, 27017, 6379 or 5432.

## 7. Start and verify

The first boot is slow. The image pull is about 1.5 GB, then three database engines initialise
and the server runs its migrations before it answers anything. Upstream says this can take up
to five minutes; on a small box it takes longer.

```bash
cd /srv/appsmith
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v1/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/v1/health
curl -sS https://<DOMAIN>/api/v1/tenants/current | grep -o '"isSignupDisabled":[a-z]*'
curl -sS https://<DOMAIN>/ | grep -c 'Appsmith is starting.'
```

Assert, all four, and print what you received for each. The loop ends printing `200`. The
health response contains `"data":"All systems are up"`, which is the string the server returns
once both MongoDB and Redis answer it. The third command prints `"isSignupDisabled":true`, and
that is the security assert in this block: signup is shut before any account exists, rather
than being opened and closed around a window somebody else could walk through. The fourth
prints `0`, meaning the holding page the container serves while it boots is gone and the real
editor is being served. If any of the four misses, stop, run
`docker compose logs --tail 60 appsmith`, and name the likely cause: a `502` past fifteen
minutes points at step 4, a certificate error at step 5, and a container that keeps restarting
usually means the RAM floor in step 1 was measured on a box that had already given the memory
to something else. A running container is not success.

If `"isSignupDisabled"` came back `false`, do not carry on and do not create an account. The
value is written into the database on the first boot only. Reset instead, while there is
nothing to lose: `docker compose down`, `sudo rm -rf /srv/appsmith/stacks`,
`sudo install -d -m 755 /srv/appsmith/stacks`, confirm step 3's `.env` still has
`APPSMITH_SIGNUP_DISABLED=true` in it, then `docker compose up -d` and run this block again.

The first screen at https://<DOMAIN> is the welcome form, headed `Almost there` over
`Let's setup your account first`, with fields for a first name, last name, `Email` and a
password typed twice.

STOP: tell the user to open https://<DOMAIN>, fill that form in using exactly `<ADMIN_EMAIL>`
as the email address, and wait. Do not continue until they confirm. That address is the one
exception to the signup lock, so any other gets an error beginning `Signup is restricted on
this instance of Appsmith`, and the account created here becomes the instance administrator.
Tell them to put the password in their password manager as they type it: there is no mail
server here, so there is no reset link.

## 8. First backup and restore

One archive, taken with the container stopped. Three database engines are writing inside that
directory and a tar of a live MongoDB is not a backup, it is a file that looks like one.

```bash
cd /srv/appsmith
docker compose stop
sudo tar -czf /srv/appsmith/backups/appsmith-$(date +%F).tar.gz -C /srv/appsmith compose.yml .env stacks -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/appsmith/backups/
```

Assert: the archive exists and is non-empty. Print its size. The stop and start cost a few
minutes of downtime, because the container has to bring all three engines back up. Upstream
also ships `appsmithctl backup`, which prompts for an encryption password at the terminal;
this archive answers to no prompt, which is what makes it runnable from cron.

A backup on the same disk as the data is not a backup. Run this from the user's machine:

```bash
mkdir -p ~/backups/appsmith
scp vps:/srv/appsmith/backups/*.tar.gz ~/backups/appsmith/
```

To restore: `docker compose down`, `sudo rm -rf /srv/appsmith/stacks`, then
`sudo tar -xzf /srv/appsmith/backups/<archive> -C /srv/appsmith`, then `docker compose up -d`.
Untar it with sudo, always, because the archive carries the uid the embedded PostgreSQL owns
its data directory as, and an extract that flattens those owners gives a container that starts
and a database that does not. Tell the user those four commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/appsmithorg/appsmith/releases. Take the backup
from step 8 first, then edit the image line in /srv/appsmith/compose.yml to the new tag and its
digest:

```bash
cd /srv/appsmith
docker compose pull
docker compose up -d
docker compose logs --tail 40 appsmith
```

Appsmith runs its own database migrations on the way up, so watch that log until it settles,
then re-run the health check from step 7 before calling the update done.

## 10. What will probably go wrong

The wait. I brought this up on an 8 GB box, watched a page that said `Appsmith is starting.`
for nine minutes, decided the proxy was misconfigured, and started taking the Caddy block
apart. Nothing was wrong. That page is served by the container itself while three database
engines initialise behind it, and it is replaced the moment the server's health check passes.
The way to tell waiting from broken is the loop in step 7: if it is still printing `502` or
`000` after fifteen minutes, then something is actually wrong, and until then the honest answer
is that it is still coming up.

## 11. Out of scope

- Do not set `APPSMITH_CUSTOM_DOMAIN`. It makes the container request its own Let's Encrypt
  certificate on port 443, which fights the Caddy that already holds the hostname.
- Do not configure SMTP. Appsmith runs without it; what it costs is invitation email and
  password-reset email, and that is a trade the user makes later, not a step here.
- Do not point `APPSMITH_DB_URL` or `APPSMITH_REDIS_URL` at an external database. The embedded
  ones are the shape of this install and moving them is a migration, not a setting.
- Do not install the `appsmith-ee` image or ask the user for a license key. This prompt
  installs the community edition, which is the Apache-2.0 one.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Appsmith v2.2 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, replace `<DOMAIN>` with the hostname whose A record already points at the
box, and replace `<ADMIN_EMAIL>` with the address you intend to sign in as.

Read this before step 1. Appsmith is a heavy container: one image runs the application,
MongoDB, Redis and PostgreSQL together, and upstream asks for 8 GB of RAM on the host. A 2 GB
droplet will not run it, and finding that out at step 7 costs you an hour. Check the box you
have before you start.

`<ADMIN_EMAIL>` matters more here than in most installs. This install closes signup before the
first boot and names that one address as the exception, so it is the only address that can
create an account afterwards. Choose it now, write it in lowercase, and use exactly the same
characters when the welcome form asks for it.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `8192` MB available, at least `20` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a
minute, run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that
does not resolve, and failed attempts count against a rate limit you cannot see. A RAM number
under 8192 is the one to take seriously rather than push through: three database engines and a
JVM in one container is what the floor is describing, and the OOM killer arrives partway
through the first boot, which reads as a random failure rather than as a decision you made at
checkout.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/appsmith /srv/appsmith/backups
sudo install -d -m 755 /srv/appsmith/stacks
ls -la /srv/appsmith
```

You should see: `backups` owned by you, and `stacks` owned by root.

If you do not: leave `stacks` owned by root on purpose. The container starts as root and hands
its embedded PostgreSQL a data directory it chowns to its own uid; a directory you have already
chowned to yourself gets in the way of that and of the restore in step 8. Everything the
instance keeps, from the MongoDB files to the git checkouts to the docker.env it writes for
itself, lands under that one directory.

## 3. Secrets

Two secrets: the encryption password and the encryption salt. Both are generated here, on the
server, and both go straight into a file only you can read. They are what encrypt every
database password, API key and token you later hand to a datasource, which is why they are
worth generating yourself rather than letting the container pick a shorter pair for you.

```bash
umask 077
cat > /srv/appsmith/.env <<EOF
APPSMITH_ADMIN_EMAILS=<ADMIN_EMAIL>
APPSMITH_SIGNUP_DISABLED=true
APPSMITH_BASE_URL=https://<DOMAIN>
APPSMITH_ENCRYPTION_PASSWORD=$(openssl rand -hex 32)
APPSMITH_ENCRYPTION_SALT=$(openssl rand -hex 32)
EOF
chmod 600 /srv/appsmith/.env
umask 022
ls -l /srv/appsmith/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>`
and `<ADMIN_EMAIL>` with your real values before you paste. Read the two keys once with
`sudo grep -E 'ENCRYPTION_PASSWORD|ENCRYPTION_SALT' /srv/appsmith/.env` and put them in your
password manager tonight: a data directory restored without them comes back with every app
intact and every datasource unable to decrypt its own credentials.

Do not paste that file, either key, or any output containing them into this chat window. The
agent path never sees those values; this path hands them to a third party unless you make a
point of not doing it.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/appsmith/.env` and
carry on. If the file already existed from an earlier attempt, this block has now replaced both
keys, which is harmless before the first boot and a real problem afterwards, because saved
datasource credentials were encrypted with the old pair.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/appsmith/compose.yml <<'EOF'
# Appsmith · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.appsmith.com/getting-started/setup/installation-guides/docker
#   variable reference . https://docs.appsmith.com/getting-started/setup/environment-variables
#   capacity planning .. https://docs.appsmith.com/getting-started/setup/infrastructure-sizing
#   backup and restore . https://docs.appsmith.com/getting-started/setup/instance-management/appsmithctl
#
# One service, and it is a heavy one. Upstream ships an all-in-one image that
# runs MongoDB, Redis and PostgreSQL inside this same container under
# supervisord, which is why no database service appears below and why the RAM
# floor is 8 GB rather than the few hundred megabytes a web app alone would
# want. Everything the instance keeps, including the docker.env it generates
# for itself on first boot, lives under /appsmith-stacks.
#
# APPSMITH_CUSTOM_DOMAIN is deliberately never set here: setting it makes the
# container ask Let's Encrypt for a certificate of its own, and the host's
# Caddy already terminates TLS. The container's 443 is therefore never
# published and only its plain-http 80 is. Tag and digest were read from the
# registry on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  appsmith:
    image: appsmith/appsmith-ce:v2.2@sha256:dc17b968c88eebf42b85c2e22b97efb55f2339b2d685e48f804c5f87bdd9d4e5
    container_name: appsmith
    restart: unless-stopped
    env_file: /srv/appsmith/.env
    environment:
      # Upstream ships anonymous usage collection turned on. This turns it off.
      APPSMITH_DISABLE_TELEMETRY: "true"
      # The docker.env the container writes for itself lets any site on the
      # internet load these apps in an iframe. This narrows the
      # Content-Security-Policy back to this hostname and nothing else.
      APPSMITH_ALLOWED_FRAME_ANCESTORS: "'self'"
    volumes:
      - /srv/appsmith/stacks:/appsmith-stacks
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8143.
      - "127.0.0.1:8143:80"
EOF
cd /srv/appsmith && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/appsmith/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/appsmith/compose.yml` and paste again in one go. Nothing here publishes 443,
because the container only holds a certificate when `APPSMITH_CUSTOM_DOMAIN` is set, and
setting it would put a second certificate authority client on a box where Caddy already owns
the hostname.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-appsmith
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Appsmith · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.appsmith.com/getting-started/setup/installation-guides/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APPSMITH_BASE_URL in .env, which is the value Appsmith compares the
# Origin header of a password-reset request against, so the two have to agree.

<DOMAIN> {
	# The container runs a Caddy of its own and already sends
	# Content-Security-Policy and X-Content-Type-Options on every response, so
	# this block does not restate them and cannot contradict them. HSTS is
	# here because nothing inside the container knows it is being served over
	# https. There is no `encode` either: the inner Caddy compresses already,
	# and compressing a second time costs CPU for no bytes.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8143 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. The editor holds a
	# WebSocket open to /rts, and reverse_proxy carries that upgrade with no
	# extra configuration.
	reverse_proxy 127.0.0.1:8143
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-appsmith /etc/caddy/Caddyfile`, reload,
and paste again. The most common cause is a `<DOMAIN>` you replaced in one place and not the
other, which leaves a site block Caddy will happily try to get a certificate for.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8143`, `27017`, `6379` or `5432`.

If you do not: delete anything for `8143` with `sudo ufw delete allow 8143`. The MongoDB, Redis
and PostgreSQL live inside the container and listen on the container's own loopback, so they
have no host port a firewall rule could apply to at all. 80/tcp redirects to HTTPS and answers
the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

The first boot is slow. The image pull is about 1.5 GB, then three database engines initialise
and the server runs its migrations before it answers anything. Upstream says this can take up
to five minutes; on a small box it takes longer. The loop below waits fifteen minutes.

```bash
cd /srv/appsmith
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v1/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/v1/health
curl -sS https://<DOMAIN>/api/v1/tenants/current | grep -o '"isSignupDisabled":[a-z]*'
curl -sS https://<DOMAIN>/ | grep -c 'Appsmith is starting.'
```

You should see, in order: the loop climbing through `502` and reaching `200`, a JSON object
containing `"data":"All systems are up"`, then `"isSignupDisabled":true`, then `0`.

If you do not: the `"isSignupDisabled":true` is the one worth understanding. It means signup was
shut before any account existed, so nobody could have walked through a window while you were
reading this. If it comes back `false`, stop and do not create an account: that value is written
into the database on the first boot only, and no later edit of `.env` changes it. Start over
while there is nothing to lose, with `docker compose down`, `sudo rm -rf /srv/appsmith/stacks`,
`sudo install -d -m 755 /srv/appsmith/stacks`, a check that `.env` really contains
`APPSMITH_SIGNUP_DISABLED=true`, and `docker compose up -d`. A last line of `1` rather than `0`
means the container is still serving its own holding page and is not finished booting, so give
it longer. A loop that never leaves `502` after fifteen minutes is real: run
`docker compose logs --tail 60 appsmith` and look for the container restarting, which is almost
always memory.

The first screen at https://<DOMAIN> is the welcome form, headed `Almost there` over
`Let's setup your account first`, with fields for a first name, last name, `Email` and a
password typed twice.

Open it now and fill it in, using exactly `<ADMIN_EMAIL>` as the email address. Any other
address gets an error beginning `Signup is restricted on this instance of Appsmith`, because
that is what step 3 configured. Put the password in your password manager while you are typing it:
there is no mail server on this install, so there is no reset link, and a forgotten password
here means editing the database by hand.

## 8. First backup and restore

One archive, taken with the container stopped. Three database engines are writing inside that
directory and a tar of a live MongoDB is not a backup, it is a file that looks like one.

```bash
cd /srv/appsmith
docker compose stop
sudo tar -czf /srv/appsmith/backups/appsmith-$(date +%F).tar.gz -C /srv/appsmith compose.yml .env stacks -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/appsmith/backups/
```

You should see: one file, a few hundred megabytes on a fresh install, because three empty
database engines are still three database engines. The stop and start cost a few minutes of
downtime while the container brings all of them back up.

If you do not: a `tar: Removing leading /` warning is normal and not an error. `Permission
denied` means you dropped the `sudo`, which you need because `stacks` belongs to root.
Upstream also ships `appsmithctl backup`, which prompts for an encryption password at the
terminal and refuses to restore without it; the archive above answers to no prompt, which is
what makes it runnable from cron later.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/appsmith
scp vps:/srv/appsmith/backups/*.tar.gz ~/backups/appsmith/
```

You should see: one file copied, and it listed by `ls -lh ~/backups/appsmith/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the `vps` alias Prompt Zero created
lives.

Now prove the restore, today, while the only thing at risk is an empty instance:

```bash
cd /srv/appsmith
docker compose down
sudo rm -rf /srv/appsmith/stacks
sudo tar -xzf /srv/appsmith/backups/appsmith-$(date +%F).tar.gz -C /srv/appsmith
docker compose up -d
sleep 300
curl -sS https://<DOMAIN>/api/v1/health
```

You should see: `"data":"All systems are up"` again, and your account still able to sign in.

If you do not: untar with `sudo`, always. The archive carries the uid the embedded PostgreSQL
owns its data directory as, and an extract that flattens those owners gives you a container
that starts and a database that does not. Five minutes is the shortest wait worth giving it.

## 9. Updating later

New versions are listed at https://github.com/appsmithorg/appsmith/releases. Take the backup
above first, then edit the `image:` line in /srv/appsmith/compose.yml to the new tag and its
digest.

```bash
cd /srv/appsmith
docker compose pull
docker compose up -d
docker compose logs --tail 40 appsmith
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done, and open one of your own apps as
well, because a server that answers `All systems are up` can still be failing on a migration
that stopped halfway.

## 10. What will probably go wrong

The wait. I brought this up on an 8 GB box, watched a page that said `Appsmith is starting.`
for nine minutes, decided the proxy was misconfigured, and started taking the Caddy block
apart. Nothing was wrong. That page is served by the container itself while three database
engines initialise behind it, and it is replaced the moment the server's health check passes.
The way to tell waiting from broken is the loop in step 7: if it is still printing `502` or
`000` after fifteen minutes, then something is actually wrong, and until then the honest answer
is that it is still coming up.

## 11. Out of scope

- Do not set `APPSMITH_CUSTOM_DOMAIN`. It makes the container request its own Let's Encrypt
  certificate on port 443, which fights the Caddy that already holds the hostname.
- Do not configure SMTP. Appsmith runs without it; what it costs is invitation email and
  password-reset email, and that is a trade you make later, not a step here.
- Do not point `APPSMITH_DB_URL` or `APPSMITH_REDIS_URL` at an external database. The embedded
  ones are the shape of this install and moving them is a migration, not a setting.
- Do not install the `appsmith-ee` image or enter a license key. This installs the community
  edition, which is the Apache-2.0 one.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Appsmith v2.2 under ~/selfhost/appsmith, answering at http://localhost:8143.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
Appsmith builds internal tools for other people to use, and here the only address those tools
have is http://localhost:8143, which means "this computer" wherever it is read. The user gets
the builder and the apps; the colleague they meant to hand a form to gets a connection error.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Appsmith needs 8192 MB of RAM available
and 20 GB free on the home disk; the image publishes amd64 and arm64. That floor is real: one
container runs the application, MongoDB, Redis and PostgreSQL together, and upstream's baseline
is 2 vCPU and 8 GB. On macOS and Windows the number printed is the host's, and Docker Desktop's
virtual machine takes its allocation out of that, so a 16 GB laptop capped at 4 GB will not run
this. If available RAM is under 8192 MB or free disk is under 20 GB, print both and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/appsmith/backups
ls -la ~/selfhost/appsmith
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder on purpose: step
5 keeps the instance's directory in a named Docker volume, because the embedded PostgreSQL
chowns its data directory to a uid Windows file sharing will not grant on a bind mount.

## 4. Secrets

Two secrets: the encryption password and the encryption salt. They encrypt every database
password, API key and token the user later hands to a datasource. Generate both here, print
neither, and keep both out of your summary and out of any log.

This file also carries the address that will own the instance. STOP: ask the user which email
address they want to sign in with, and wait. Do not continue until they answer. Take it in
lowercase. It is the one address that can create an account here, because the same file closes
signup for everyone else, and step 7 types it into a form character for character.

Write the file below with that address in place of `you@example.com`:

```bash
umask 077
cat > ~/selfhost/appsmith/.env <<EOF
APPSMITH_ADMIN_EMAILS=you@example.com
APPSMITH_SIGNUP_DISABLED=true
APPSMITH_BASE_URL=http://localhost:8143
APPSMITH_ENCRYPTION_PASSWORD=$(openssl rand -hex 32)
APPSMITH_ENCRYPTION_SALT=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/appsmith/.env
umask 022
ls -l ~/selfhost/appsmith/.env
```

Assert: the file exists with mode `-rw-------`, and the first line carries the address the user
gave rather than the example one. Git Bash ships openssl, so these lines run the same on all
three systems. Upstream generates a weaker pair inside the container when none arrive from
outside, and outside values win. Tell the user to read these once with
`grep ENCRYPTION ~/selfhost/appsmith/.env` and put them in their password manager: a
volume restored without them comes back with every app intact and every datasource unable to
decrypt its own credentials.

On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary is
the user's own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/appsmith/compose.yml <<'EOF'
# Appsmith · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.appsmith.com/getting-started/setup/installation-guides/docker
#   variable reference . https://docs.appsmith.com/getting-started/setup/environment-variables
#   capacity planning .. https://docs.appsmith.com/getting-started/setup/infrastructure-sizing
#
# One heavy service: upstream's all-in-one image runs MongoDB, Redis and
# PostgreSQL inside this container under supervisord, which is where the 8 GB
# floor comes from. /appsmith-stacks is a named volume, not a relative bind
# mount, because the embedded PostgreSQL chowns its data directory to its own
# uid and Windows file sharing cannot grant that on a home folder. ./backups
# stays a real folder. Digest read on 2026-08-06; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  appsmith:
    image: appsmith/appsmith-ce:v2.2@sha256:dc17b968c88eebf42b85c2e22b97efb55f2339b2d685e48f804c5f87bdd9d4e5
    container_name: appsmith
    restart: unless-stopped
    env_file: ./.env
    environment:
      # Upstream ships anonymous usage collection turned on. This turns it off.
      APPSMITH_DISABLE_TELEMETRY: "true"
      # The docker.env the container writes for itself lets any site load
      # these apps in an iframe. This narrows it back to this address only.
      APPSMITH_ALLOWED_FRAME_ANCESTORS: "'self'"
    volumes:
      - appsmith-stacks:/appsmith-stacks
      - ./backups:/backup
    ports:
      # Loopback only: no other device on the wifi can reach 8143.
      - "127.0.0.1:8143:80"

volumes:
  appsmith-stacks:
EOF
cd ~/selfhost/appsmith && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS, because there is no hostname to resolve.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so the editor's crypto still works.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8143 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a laptop
on the same wifi, nor anyone on the internet. For a tool whose apps are meant to be handed to
other people, that is the shape of the trade. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/appsmith/compose.yml
```

Assert: that prints `1`. The three databases inside the container are never published at all.

## 7. Start and verify

The first boot is slow. The pull is about 1.5 GB, then three database engines initialise and
the server migrates before it answers anything. Upstream says up to five minutes; on a laptop
sharing its cores with everything else, longer.

```bash
cd ~/selfhost/appsmith
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8143/api/v1/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8143/api/v1/health
curl -sS http://localhost:8143/api/v1/tenants/current | grep -o '"isSignupDisabled":[a-z]*'
curl -sS http://localhost:8143/ | grep -c 'Appsmith is starting.'
```

Assert, all four, and print what you received for each. The loop ends printing `200`. The
health response contains `"data":"All systems are up"`, the string the server returns once both
MongoDB and Redis answer it. The third prints `"isSignupDisabled":true`, the security assert
here: signup is shut before any account exists. The fourth prints `0`, meaning the holding page
the container serves while it boots is gone. If any of the four misses, stop, run
`docker compose logs --tail 60 appsmith`, and name the likely cause: a container restarting in
a loop is usually Docker Desktop's memory cap, and `port is already allocated` means something
else holds 8143 (`lsof -nP -iTCP:8143 -sTCP:LISTEN`, or `netstat -ano | findstr :8143` on
Windows). A running container is not success.

If `"isSignupDisabled"` came back `false`, do not create an account: that value is written into
the database on the first boot only. Reset instead, while there is nothing to lose:
`docker compose down -v`, check step 4's `.env` still says `APPSMITH_SIGNUP_DISABLED=true`, then
`docker compose up -d`, and run this block again.

The first screen is the welcome form, headed `Almost there` over `Let's setup your account
first`, with fields for a first name, last name, `Email` and a password typed twice.

STOP: tell the user to open http://localhost:8143, fill that form in using exactly the address
they gave in step 4, and wait. Do not continue until they confirm. Any other address gets an
error beginning `Signup is restricted on this instance of Appsmith`. Tell them to put the
password in their password manager as they type it: there is no mail here, so no reset link.

## 8. First backup and restore

One archive, taken with the container stopped, because a tar of a live MongoDB is not a backup.
The tar runs inside a throwaway container so the uids the embedded PostgreSQL owns its files as
survive into the archive:

```bash
cd ~/selfhost/appsmith
docker compose stop
docker run --rm --volumes-from appsmith --entrypoint sh appsmith/appsmith-ce:v2.2@sha256:dc17b968c88eebf42b85c2e22b97efb55f2339b2d685e48f804c5f87bdd9d4e5 -c "tar -czf /backup/appsmith-$(date +%F).tar.gz -C / appsmith-stacks"
docker compose start
ls -lh ~/selfhost/appsmith/backups/
```

Assert: the archive exists and is non-empty. Print its size. The stop and start cost a few
minutes, while the container brings all three engines back up.

That archive sits on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a folder their sync service
watches or a USB stick, and copy it there with `cp`, together with `~/selfhost/appsmith/.env`,
which holds the keys that decrypt its datasource credentials. In Git Bash a Windows drive is written
`/d/Backups`, not `D:\Backups`. Assert: the user confirms both filenames are listed there. If
they have neither, say plainly that this install has no backup.

To restore, in this order. `cd ~/selfhost/appsmith`, put `.env` and `compose.yml` back if they
are missing, then `docker compose down -v`, which drops the old volume on purpose, then
`docker compose create`, which makes an empty one. Then the same `docker run` line as above,
with `tar -xzf` and the archive's filename in place of `tar -czf` and the date, extracting with
`-C /`. Then `docker compose up -d` and re-run step 7's check. That is the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/appsmithorg/appsmith/releases. Take step 8's
backup first, then edit the image line in ~/selfhost/appsmith/compose.yml:

```bash
cd ~/selfhost/appsmith
docker compose pull
docker compose up -d
docker compose logs --tail 40 appsmith
```

Appsmith migrates its own databases on the way up. Watch that log until it settles, then re-run
step 7's check before calling the update done.

## 10. What will probably go wrong

Docker Desktop's memory cap. I gave this a laptop with 16 GB in it and watched the container
restart in a loop for twenty minutes, reading the log for a mistake that was not there. Docker
Desktop was handing its virtual machine 4 GB, and three database engines and a JVM do not fit
in 4 GB. The number step 1 printed was the laptop's, not Docker's. Open Docker Desktop,
Settings, Resources, give it at least 8 GB, apply and restart, then run step 7 again.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8143 to 0.0.0.0 so a colleague on the wifi can open an app. That publishes an
  internal-tools builder, and its saved database credentials, onto every network this computer
  joins.
- Do not configure SMTP, and do not set `APPSMITH_CUSTOM_DOMAIN`. A custom domain makes the
  container ask Let's Encrypt for a certificate no public name backs.
````

## docker-compose.yml

```yaml
# Appsmith · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.appsmith.com/getting-started/setup/installation-guides/docker
#   variable reference . https://docs.appsmith.com/getting-started/setup/environment-variables
#   capacity planning .. https://docs.appsmith.com/getting-started/setup/infrastructure-sizing
#   backup and restore . https://docs.appsmith.com/getting-started/setup/instance-management/appsmithctl
#
# One service, and it is a heavy one. Upstream ships an all-in-one image that
# runs MongoDB, Redis and PostgreSQL inside this same container under
# supervisord, which is why no database service appears below and why the RAM
# floor is 8 GB rather than the few hundred megabytes a web app alone would
# want. Everything the instance keeps, including the docker.env it generates
# for itself on first boot, lives under /appsmith-stacks.
#
# APPSMITH_CUSTOM_DOMAIN is deliberately never set here: setting it makes the
# container ask Let's Encrypt for a certificate of its own, and the host's
# Caddy already terminates TLS. The container's 443 is therefore never
# published and only its plain-http 80 is. Tag and digest were read from the
# registry on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  appsmith:
    image: appsmith/appsmith-ce:v2.2@sha256:dc17b968c88eebf42b85c2e22b97efb55f2339b2d685e48f804c5f87bdd9d4e5
    container_name: appsmith
    restart: unless-stopped
    env_file: /srv/appsmith/.env
    environment:
      # Upstream ships anonymous usage collection turned on. This turns it off.
      APPSMITH_DISABLE_TELEMETRY: "true"
      # The docker.env the container writes for itself lets any site on the
      # internet load these apps in an iframe. This narrows the
      # Content-Security-Policy back to this hostname and nothing else.
      APPSMITH_ALLOWED_FRAME_ANCESTORS: "'self'"
    volumes:
      - /srv/appsmith/stacks:/appsmith-stacks
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8143.
      - "127.0.0.1:8143:80"
```

## compose.local.yml

```yaml
# Appsmith · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.appsmith.com/getting-started/setup/installation-guides/docker
#   variable reference . https://docs.appsmith.com/getting-started/setup/environment-variables
#   capacity planning .. https://docs.appsmith.com/getting-started/setup/infrastructure-sizing
#
# One heavy service: upstream's all-in-one image runs MongoDB, Redis and
# PostgreSQL inside this container under supervisord, which is where the 8 GB
# floor comes from. /appsmith-stacks is a named volume, not a relative bind
# mount, because the embedded PostgreSQL chowns its data directory to its own
# uid and Windows file sharing cannot grant that on a home folder. ./backups
# stays a real folder. Digest read on 2026-08-06; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  appsmith:
    image: appsmith/appsmith-ce:v2.2@sha256:dc17b968c88eebf42b85c2e22b97efb55f2339b2d685e48f804c5f87bdd9d4e5
    container_name: appsmith
    restart: unless-stopped
    env_file: ./.env
    environment:
      # Upstream ships anonymous usage collection turned on. This turns it off.
      APPSMITH_DISABLE_TELEMETRY: "true"
      # The docker.env the container writes for itself lets any site load
      # these apps in an iframe. This narrows it back to this address only.
      APPSMITH_ALLOWED_FRAME_ANCESTORS: "'self'"
    volumes:
      - appsmith-stacks:/appsmith-stacks
      - ./backups:/backup
    ports:
      # Loopback only: no other device on the wifi can reach 8143.
      - "127.0.0.1:8143:80"

volumes:
  appsmith-stacks:
```

## Caddyfile

```text
# Appsmith · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.appsmith.com/getting-started/setup/installation-guides/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APPSMITH_BASE_URL in .env, which is the value Appsmith compares the
# Origin header of a password-reset request against, so the two have to agree.

<DOMAIN> {
	# The container runs a Caddy of its own and already sends
	# Content-Security-Policy and X-Content-Type-Options on every response, so
	# this block does not restate them and cannot contradict them. HSTS is
	# here because nothing inside the container knows it is being served over
	# https. There is no `encode` either: the inner Caddy compresses already,
	# and compressing a second time costs CPU for no bytes.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8143 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. The editor holds a
	# WebSocket open to /rts, and reverse_proxy carries that upgrade with no
	# extra configuration.
	reverse_proxy 127.0.0.1:8143
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Appsmith · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=apps.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.appsmith.com/getting-started/setup/installation-guides/docker
#   https://docs.appsmith.com/getting-started/setup/environment-variables
#   https://docs.appsmith.com/getting-started/setup/infrastructure-sizing
#   https://docs.appsmith.com/getting-started/setup/instance-management/appsmithctl
#
# Two secrets are generated here, on this machine: the encryption password and
# the encryption salt. Both go into /srv/appsmith/.env with mode 600 and neither
# is ever printed. They encrypt every datasource credential the instance stores,
# so a data directory restored without them comes back undecryptable.
#
# ADMIN_EMAIL is the only address that will be able to create an account, because
# this install closes signup before the container's first boot and names that
# address as the exception. Use it verbatim in the welcome form.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/appsmith}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. apps.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address that will own this instance, in lowercase"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 8192 ] || die "only ${avail_mb} MB of RAM available; the all-in-one image wants 8192 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# stacks stays owned by root: the container starts as root and hands its
# embedded PostgreSQL a data directory it chowns to its own uid.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 755 "$APP_DIR/stacks"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64: both travel through a Java property loader and neither
# wants escaping. Read them later with
#   sudo grep -E 'ENCRYPTION_PASSWORD|ENCRYPTION_SALT' /srv/appsmith/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		APPSMITH_ADMIN_EMAILS=${ADMIN_EMAIL}
		APPSMITH_SIGNUP_DISABLED=true
		APPSMITH_BASE_URL=https://${DOMAIN_HOST}
		APPSMITH_ENCRYPTION_PASSWORD=$(openssl rand -hex 32)
		APPSMITH_ENCRYPTION_SALT=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-appsmith"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8143 is not one of them -------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8143 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first boot initialises MongoDB, Redis and PostgreSQL inside the container
# and runs the server's migrations. Upstream says up to five minutes; this waits
# fifteen.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/v1/health"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v1/health" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/api/v1/health answered ${code:-nothing}. Check: docker compose logs --tail 60 appsmith"

curl -sS "https://${DOMAIN_HOST}/api/v1/health" | grep -q '"data":"All systems are up"' \
	|| die "health answered 200 without the expected body. Check: docker compose logs --tail 60 appsmith"

# Signup must already be shut, before any account exists. This value is written
# into the database on the first boot only, so a false here is not fixable by
# editing .env: destroy stacks/ and start over while nothing is at stake.
curl -sS "https://${DOMAIN_HOST}/api/v1/tenants/current" | grep -q '"isSignupDisabled":true' \
	|| die "signup is still open. Stop, run: docker compose down && sudo rm -rf ${APP_DIR}/stacks, then rerun this script."

# The container serves its own holding page until the server is ready. Once the
# real editor is being served, that string is gone.
if curl -sS "https://${DOMAIN_HOST}/" | grep -q 'Appsmith is starting.'; then
	die "the holding page is still being served. Wait, then re-run the health check."
fi

# --- 7. The first backup, before day one ends --------------------------------
#
# Stopped, because three database engines are writing in there and a tar of a
# live MongoDB is a file that looks like a backup.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/appsmith-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env stacks -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/appsmith-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	Appsmith is answering at https://${DOMAIN_HOST}/api/v1/health

	  1. Open https://${DOMAIN_HOST} and create the first account. The welcome
	     form is headed "Almost there". Use exactly
	       ${ADMIN_EMAIL}
	     as the email address: signup is closed for every other address, and
	     that account becomes the instance administrator.
	  2. There is no mail server here, so there is no password reset. Put that
	     password in your password manager as you type it.
	  3. Your encryption keys are in $APP_DIR/.env, mode 600. Read them with
	       sudo grep -E 'ENCRYPTION_PASSWORD|ENCRYPTION_SALT' $APP_DIR/.env
	     and keep them: a restore without them cannot decrypt the datasource
	     credentials you save. They were not printed here.
	  4. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight:
	       scp vps:$APP_DIR/backups/*.tar.gz ~/backups/appsmith/
	     Restore is: docker compose down, sudo rm -rf $APP_DIR/stacks,
	     sudo tar -xzf the archive -C $APP_DIR, docker compose up -d.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/retool/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
