# Can I self-host Make?

**YES, BUT** — it's called Automatisch. ONE WEEKEND setup · ~4 hours to running · 2 GB RAM minimum · $9/mo you stop paying ($108/yr on the Core plan) — a metered rate, not a whole bill.

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

## 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 Automatisch 0.15.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Say this when you ask: `<DOMAIN>` becomes `API_URL`, and every webhook address this instance
hands a third party is built from it, so moving it later breaks published flows. Its A record
must point at this server already.

Automatisch needs 2048 MB of RAM available and 10 GB free on /srv. All three images publish
amd64 and arm64. Measure all four first:

```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 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. Upstream publishes no sizing figure; this floor covers two Node processes,
PostgreSQL and Redis. If `dig +short` prints nothing, stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/automatisch /srv/automatisch/backups
sudo install -d -m 700 /srv/automatisch/postgres /srv/automatisch/redis
ls -la /srv/automatisch
```

Assert: `ls -la` shows `backups` owned by the login user, and `postgres` and `redis` at mode
`700` owned by root. Leave those two alone: both images chown their own data directory at first
start, and one already chowned to the login user makes PostgreSQL refuse to initialise.

## 3. Secrets

Four secrets: the key that encrypts every stored third-party credential, the key that verifies
inbound webhooks, the app secret key upstream documents as required, and the PostgreSQL password.
Generate all four on the server. Do not print any of them, do not repeat them in your summary,
and do not put them in a log line. Hex for all four: one rides inside a connection string.

```bash
umask 077
cat > /srv/automatisch/.env <<EOF
API_URL=https://<DOMAIN>
ENCRYPTION_KEY=$(openssl rand -hex 32)
WEBHOOK_SECRET_KEY=$(openssl rand -hex 32)
APP_SECRET_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/automatisch/.env
umask 022
ls -l /srv/automatisch/.env
```

Assert: the file exists with mode `-rw-------`. Tell the user what the first two do, in
upstream's own words: they encrypt the credentials of third-party services and verify webhook
requests, and if they change, existing connections and flows stop working. The first belongs in
the user's password manager tonight, read with `sudo grep ENCRYPTION_KEY /srv/automatisch/.env`.

## 4. compose.yml

```bash
cat > /srv/automatisch/compose.yml <<'EOF'
# Automatisch · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installation ....... https://automatisch.io/docs/guide/installation
#   variable reference . https://automatisch.io/docs/advanced/configuration
#   credentials ........ https://automatisch.io/docs/advanced/credentials
#   url resolution ..... https://github.com/automatisch/automatisch/blob/v0.15.0/packages/backend/src/config/app.js
#
# Four services. One image runs twice: as the web and API process, and with
# WORKER=true as the queue worker, the split upstream documents for Docker.
# PostgreSQL holds the flows, the connections and the run history; Redis holds
# the BullMQ queues and the schedule of every published flow. Upstream's own
# compose file builds from a git checkout; this one runs the image their release
# workflow publishes to ghcr.io.
#
# API_URL is the one address variable that matters: config/app.js derives the
# API base, the web app URL and the webhook URL from it, where the documented
# HOST and PORT pair would build https://host:3000 and break the app icons in
# the editor. Digests read 2026-08-07; all images publish arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# The web process and the worker share an image; compose ignores x- keys.
x-automatisch-env: &automatisch-env
  APP_ENV: production
  POSTGRES_HOST: postgres
  POSTGRES_DATABASE: automatisch
  POSTGRES_USERNAME: automatisch
  REDIS_HOST: redis
  # No seeded admin: the first account is typed on the installation screen.
  DISABLE_SEED_USER: "true"
  TELEMETRY_ENABLED: "false"

x-automatisch: &automatisch
  image: ghcr.io/automatisch/automatisch:0.15.0@sha256:3bace7a12d5fb3f5b1305a6a52232270e0e0abd8465a8b78baacb07f6ea89594
  restart: unless-stopped
  env_file: /srv/automatisch/.env
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: automatisch-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: automatisch
      POSTGRES_USER: automatisch
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/automatisch/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U automatisch -d automatisch"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    container_name: automatisch-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/automatisch/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  automatisch:
    <<: *automatisch
    container_name: automatisch
    environment: *automatisch-env
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8171.
      - "127.0.0.1:8171:3000"

  worker:
    <<: *automatisch
    container_name: automatisch-worker
    environment:
      <<: *automatisch-env
      # The one difference: this copy runs the queue, not the web process.
      WORKER: "true"
EOF
cd /srv/automatisch && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Four services, one published port, and neither the database
nor the queue publishes anything. `${POSTGRES_PASSWORD}` comes from the `.env` step 3 wrote,
which compose reads from the project directory. The image is written once, under an anchor both
app services share.

## 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.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-automatisch
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Automatisch · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://automatisch.io/docs/guide/installation 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 API_URL in .env, and every webhook address Automatisch hands a third
# party is built from it, so it is the value here you cannot change once
# published flows are running. The app sends its own frame headers, so there is
# no frame directive below.

<DOMAIN> {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting an app sends the user out to a third-party
		# consent screen and back, and some providers check the origin.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8171 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8171
}
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-automatisch, reload, and report what it objected to. Caddy requests
the certificate on the first request and renews it alone; there is nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a Prompt Zero box 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, 443/udp is
HTTP/3. 8171 stays closed because it is bound to 127.0.0.1, and 5432 and 6379 stay closed because
compose never publishes them. Assert: `ufw status verbose` prints `Status: active`, shows 80,
443/tcp and 443/udp, and no rule for 8171, 5432 or 6379.

## 7. Start and verify

The main container runs the database migrations on the way up, so the first boot is slow and
Caddy answers `502` through most of it.

```bash
cd /srv/automatisch
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/internal/api/v1/automatisch/version
curl -sS https://<DOMAIN>/internal/api/v1/automatisch/info
docker compose logs --tail 20 worker | grep -c 'Workers are ready'
```

Assert, all four, and print what you received for each. The loop ends on `200`. The version
response contains `"version":"0.15.0"`, the pinned release answering and not something already on
the box. The info response contains `"installationCompleted":false`, which is how a fresh
instance says nobody owns it yet. The worker count is `1`. If any of the four misses, stop,
run `docker compose logs --tail 40 automatisch`, and name the likely cause: a `502` past ten
minutes points at step 4, a database that never reports healthy at step 2, a certificate error at
step 5, a worker that never says it is ready at Redis. A running container is not success.

The first screen is at https://<DOMAIN>/installation, and https://<DOMAIN>/ redirects to it while
the instance has no account. It shows the heading `Installation` over a form asking for a full
name, an email and a password twice, above a button reading `Create admin`.

STOP: tell the user to open https://<DOMAIN>/, fill that form in, and wait.
Do not continue until they confirm. That account is the admin here. Then confirm the door
shut behind them:

```bash
curl -sS https://<DOMAIN>/internal/api/v1/automatisch/info | grep -o '"installationCompleted":true'
```

Assert: that prints `"installationCompleted":true`. The endpoint that creates the first admin
answers `403` from here on, and `DISABLE_SEED_USER` means the default account upstream's
entrypoint would otherwise seed never existed. Both asserts must pass before you report
success.

## 8. First backup and restore

Two artifacts: the database holds the flows, the connections and the run history, and the config
archive holds the files that rebuild the service around them, encryption key included.

```bash
cd /srv/automatisch
docker compose exec -T postgres pg_dump -U automatisch -d automatisch | gzip > /srv/automatisch/backups/automatisch-db-$(date +%F).sql.gz
sudo tar -C /srv/automatisch -czf /srv/automatisch/backups/automatisch-config-$(date +%F).tar.gz compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/automatisch/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently. Redis is in neither archive, and the last step of the
restore is what that costs.

A backup on the same disk is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/automatisch
scp vps:/srv/automatisch/backups/* ~/backups/automatisch/
```

To restore: `docker compose down`, `sudo rm -rf /srv/automatisch/postgres /srv/automatisch/redis`,
recreate both as in step 2, untar the config archive into /srv/automatisch so `.env` is back
before anything starts, `docker compose up -d postgres`, wait for healthy, pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T postgres psql -U automatisch -d automatisch`, run that same
psql command again with `-c "UPDATE flows SET active = false;"`, then `docker compose up -d`. That
last statement is the step people miss: the repeating schedule of a published flow lives in Redis,
not PostgreSQL, so a restored database describes flows nothing is scheduled to run. Clearing the
flag lets the user switch each one back on in the browser, which re-registers the schedule. A
database restored without the matching `ENCRYPTION_KEY` comes back with every flow intact and
every credential unreadable, so the two artifacts travel together or neither is worth keeping.

## 9. Updating later

New versions are listed at https://github.com/automatisch/automatisch/releases. Take both backups
first, then edit the image line in /srv/automatisch/compose.yml to the new tag and digest.

```bash
cd /srv/automatisch
docker compose pull
docker compose up -d
docker compose logs --tail 30 automatisch
```

Automatisch migrates its own database on the way up, so watch that log until it settles, then
re-run step 7's first three checks.

## 10. What will probably go wrong

Nothing will happen for fifteen minutes and it will look like the worker is dead. I published my
first flow, watched the executions page stay empty, and restarted the whole stack twice before I
read the code. Without an enterprise licence Automatisch pins every polling trigger to a
fifteen-minute cron, and the interval selector is put back to fifteen when you save it lower. The
first run is up to a quarter of an hour after publishing, every time. Before touching anything,
run `docker compose logs --tail 20 worker` and look for `Workers are ready!`. If that line is
there, the install is fine and the clock is what you are waiting for.

## 11. Out of scope

- Do not configure SMTP. No `SMTP_` variable is set here: the first admin account is made in the
  browser in step 7, and the password-reset screens live in files marked `.ee.`.
- Do not set `ENABLE_BULLMQ_DASHBOARD` or its two credential variables. That publishes the raw
  job queue on the same hostname, and this install has no reason to.
- Do not set `LICENSE_KEY`, and do not enable SAML, roles, templates or the public REST API under
  /api/v1. Those read files marked `.ee.`, which carry a separate commercial licence.
- Do not connect a third-party app yet and do not add any client id or secret to `.env`. Each
  connector needs its own developer registration in that company's portal, done from the Apps
  screen after login.
````

## 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 Automatisch 0.15.0 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, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1. `<DOMAIN>` becomes `API_URL`, and every webhook address this instance
hands a third-party service is built from it. Move it later and the flows already published keep
pointing at a name that no longer answers. Pick the hostname you intend to keep.

## 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 `2048` MB available, at least `10` 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. On the memory line,
upstream publishes no sizing figure at all; 2048 MB is this install's own floor, covering two
Node processes from one 800 MB image plus PostgreSQL and Redis. A 1 GB box will start and then
get one of the four killed under load, which reads as random.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/automatisch /srv/automatisch/backups
sudo install -d -m 700 /srv/automatisch/postgres /srv/automatisch/redis
ls -la /srv/automatisch
```

You should see: `backups` owned by you, and `postgres` and `redis` at mode `drwx------` owned by
root.

If you do not: leave those two owned by root on purpose. Both images chown their own data
directory the first time they start, and a directory you have already chowned to yourself makes
PostgreSQL refuse to initialise with a message about permissions that does not mention you.

## 3. Secrets

Four secrets, all generated here on the server, all going into one file only you can read: the
key that encrypts every stored third-party credential, the key that verifies inbound webhooks,
the app secret key upstream documents as required, and the PostgreSQL password. Hex rather than
base64, because one of them rides inside a database connection string.

```bash
umask 077
cat > /srv/automatisch/.env <<EOF
API_URL=https://<DOMAIN>
ENCRYPTION_KEY=$(openssl rand -hex 32)
WEBHOOK_SECRET_KEY=$(openssl rand -hex 32)
APP_SECRET_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/automatisch/.env
umask 022
ls -l /srv/automatisch/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.

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/automatisch/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten all four,
which is fine before the database exists and a problem afterwards: PostgreSQL keeps the password
it was created with, so a changed `POSTGRES_PASSWORD` on an existing volume shows up as an
authentication failure in the Automatisch log rather than anything about passwords.

Do not paste that file, any of those four values, or any command output containing them into this
chat window. Upstream's own warning is worth reading twice: the first two encrypt your
third-party credentials and verify webhook requests, and if they change, your existing
connections and flows stop working. Read `ENCRYPTION_KEY` once with
`sudo grep ENCRYPTION_KEY /srv/automatisch/.env` and put it in your password manager.

## 4. compose.yml

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

```bash
cat > /srv/automatisch/compose.yml <<'EOF'
# Automatisch · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installation ....... https://automatisch.io/docs/guide/installation
#   variable reference . https://automatisch.io/docs/advanced/configuration
#   credentials ........ https://automatisch.io/docs/advanced/credentials
#   url resolution ..... https://github.com/automatisch/automatisch/blob/v0.15.0/packages/backend/src/config/app.js
#
# Four services. One image runs twice: as the web and API process, and with
# WORKER=true as the queue worker, the split upstream documents for Docker.
# PostgreSQL holds the flows, the connections and the run history; Redis holds
# the BullMQ queues and the schedule of every published flow. Upstream's own
# compose file builds from a git checkout; this one runs the image their release
# workflow publishes to ghcr.io.
#
# API_URL is the one address variable that matters: config/app.js derives the
# API base, the web app URL and the webhook URL from it, where the documented
# HOST and PORT pair would build https://host:3000 and break the app icons in
# the editor. Digests read 2026-08-07; all images publish arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# The web process and the worker share an image; compose ignores x- keys.
x-automatisch-env: &automatisch-env
  APP_ENV: production
  POSTGRES_HOST: postgres
  POSTGRES_DATABASE: automatisch
  POSTGRES_USERNAME: automatisch
  REDIS_HOST: redis
  # No seeded admin: the first account is typed on the installation screen.
  DISABLE_SEED_USER: "true"
  TELEMETRY_ENABLED: "false"

x-automatisch: &automatisch
  image: ghcr.io/automatisch/automatisch:0.15.0@sha256:3bace7a12d5fb3f5b1305a6a52232270e0e0abd8465a8b78baacb07f6ea89594
  restart: unless-stopped
  env_file: /srv/automatisch/.env
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: automatisch-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: automatisch
      POSTGRES_USER: automatisch
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/automatisch/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U automatisch -d automatisch"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    container_name: automatisch-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/automatisch/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  automatisch:
    <<: *automatisch
    container_name: automatisch
    environment: *automatisch-env
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8171.
      - "127.0.0.1:8171:3000"

  worker:
    <<: *automatisch
    container_name: automatisch-worker
    environment:
      <<: *automatisch-env
      # The one difference: this copy runs the queue, not the web process.
      WORKER: "true"
EOF
cd /srv/automatisch && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/automatisch/.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/automatisch/compose.yml` and paste again in one go. The image is written once, under
a YAML anchor both application services share, so the version and digest live on one line rather
than two. The second copy adds `WORKER=true` and runs the job queue instead of the web process,
which is the split upstream documents for a Docker install.

## 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-automatisch
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Automatisch · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://automatisch.io/docs/guide/installation 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 API_URL in .env, and every webhook address Automatisch hands a third
# party is built from it, so it is the value here you cannot change once
# published flows are running. The app sends its own frame headers, so there is
# no frame directive below.

<DOMAIN> {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting an app sends the user out to a third-party
		# consent screen and back, and some providers check the origin.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8171 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8171
}
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-automatisch /etc/caddy/Caddyfile`, reload,
and paste again. Caddy requests the certificate on the first request to the hostname and renews
it on its own, so there is nothing to schedule and nothing to renew by hand.

## 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 `8171`, `5432` or `6379`.

If you do not: delete anything for those three with `sudo ufw delete allow 8171`. 8171 is bound
to 127.0.0.1 by the compose file, and PostgreSQL and Redis publish no host port at all, so
neither has a port a firewall rule could apply to. 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 before you go further.

## 7. Start and verify

The main container runs its own database migrations on the way up, so the first boot takes
minutes and Caddy answers `502` through most of them.

```bash
cd /srv/automatisch
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/internal/api/v1/automatisch/version
curl -sS https://<DOMAIN>/internal/api/v1/automatisch/info
docker compose logs --tail 20 worker | grep -c 'Workers are ready'
```

You should see, in order: the loop climbing and ending on `200`, a JSON object containing
`"version":"0.15.0"`, a second JSON object containing `"installationCompleted":false`, and then
`1`.

If you do not: the version string is the one worth checking carefully, because it is how you know
the pinned release is what answered rather than something already on the box. A `502` that never
clears after ten minutes means the container is not listening: run
`docker compose logs --tail 40 automatisch`. If the loop never starts climbing, run
`docker compose logs --tail 20 postgres` first, because a database that never reports healthy is
step 2 done wrong. A `0` from the last line means the worker is not running, which is a Redis
problem rather than an Automatisch one, and nothing you publish later will ever execute.

Now open a browser. https://<DOMAIN>/ redirects to https://<DOMAIN>/installation while the
instance has no account, and that screen shows the heading `Installation` over a form asking for
a full name, an email and a password twice, above a button reading `Create admin`. Fill it in.
That account is the admin of this instance. Then confirm the door shut behind you:

```bash
curl -sS https://<DOMAIN>/internal/api/v1/automatisch/info | grep -o '"installationCompleted":true'
```

You should see: `"installationCompleted":true`.

If you do not: the form did not submit. Reload https://<DOMAIN>/installation and look for a red
alert under the fields; a password under six characters and two passwords that do not match are
both rejected in the browser without reaching the server. Once this prints `true`, the endpoint
that creates the first admin answers `403` to everyone forever. This install also sets
`DISABLE_SEED_USER`, so the default account upstream's entrypoint would otherwise create never
existed on your box at all.

## 8. First backup and restore

Two artifacts. The database holds the flows, the connections and the run history. The config
archive holds the files that rebuild the service around them, encryption key included.

```bash
cd /srv/automatisch
docker compose exec -T postgres pg_dump -U automatisch -d automatisch | gzip > /srv/automatisch/backups/automatisch-db-$(date +%F).sql.gz
sudo tar -C /srv/automatisch -czf /srv/automatisch/backups/automatisch-config-$(date +%F).tar.gz compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/automatisch/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline;
`pg_dump` snapshots a running database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

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/automatisch
scp vps:/srv/automatisch/backups/* ~/backups/automatisch/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/automatisch/`.

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 alias Prompt Zero created lives.

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

```bash
cd /srv/automatisch
docker compose down
sudo rm -rf /srv/automatisch/postgres
sudo install -d -m 700 /srv/automatisch/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/automatisch/backups/automatisch-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U automatisch -d automatisch
docker compose exec -T postgres psql -U automatisch -d automatisch -c "UPDATE flows SET active = false;"
docker compose up -d
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `UPDATE 0` from the second
command, then the containers coming back.

If you do not: `role "automatisch" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. That `UPDATE flows` line is the
step people miss, and on a real restore it will not print `UPDATE 0`. The repeating schedule of
every published flow lives in Redis, not in PostgreSQL, so a restored database describes flows
that nothing is scheduled to run. Clearing the flag lets you switch each one back on in the
browser, which is what re-registers its schedule. Understand the other stake too: a database
restored without the matching `ENCRYPTION_KEY` comes back with every flow intact and every stored
credential unreadable, so the two artifacts travel together or neither is worth keeping.

## 9. Updating later

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

```bash
cd /srv/automatisch
docker compose pull
docker compose up -d
docker compose logs --tail 30 automatisch
```

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
step 7's first three checks before you call the update done, because a service that answers on
/healthcheck can still be a half-finished migration.

## 10. What will probably go wrong

Nothing will happen for fifteen minutes and it will look like the worker is dead. I published my
first flow, watched the executions page stay empty, and restarted the whole stack twice before I
read the code. Without an enterprise licence Automatisch pins every polling trigger to a
fifteen-minute cron, and the interval selector is put back to fifteen when you save it lower. The
first run is up to a quarter of an hour after publishing, every time. Before touching anything,
run `docker compose logs --tail 20 worker` and look for `Workers are ready!`. If that line is
there, the install is fine and the clock is what you are waiting for.

## 11. Out of scope

- Do not configure SMTP. No `SMTP_` variable is set here: the first admin account is made in the
  browser in step 7, and the password-reset screens live in files marked `.ee.`.
- Do not set `ENABLE_BULLMQ_DASHBOARD` or its two credential variables. That publishes the raw
  job queue on the same hostname, and this install has no reason to.
- Do not set `LICENSE_KEY`, and do not enable SAML, roles, templates or the public REST API under
  /api/v1. Those read files marked `.ee.`, which carry a separate commercial licence.
- Do not connect a third-party app yet and do not add any client id or secret to `.env`. Each
  connector needs its own developer registration in that company's portal, done from the Apps
  screen after login.
````

## 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 Automatisch 0.15.0, with the PostgreSQL and Redis it runs on, under
~/selfhost/automatisch, at http://localhost:8171.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
Automatisch checks its triggers every fifteen minutes and only while this computer is awake, so a
sleeping machine runs no flows, and the webhook address it hands a third party begins with
http://localhost:8171, which nothing outside can reach. The polling half is what works here.

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. This install needs 2048 MB of RAM available
and 10 GB free on the home disk, and all three images publish amd64 and arm64. On macOS and
Windows that figure is the host's, and Docker Desktop takes its share of it. If it is under
2048 MB, or disk under 10 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/automatisch/backups
ls -la ~/selfhost/automatisch
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: flows and run
history are rows in PostgreSQL and the queue is in Redis, both in volumes Docker manages, so
nothing here needs a chown.

## 4. Secrets

Four secrets: the key that encrypts every stored third-party credential, the key that verifies
inbound webhooks, the app secret key upstream documents as required, and the PostgreSQL password.
Generate all four here, print none, keep them out of your summary and any log.

```bash
umask 077
cat > ~/selfhost/automatisch/.env <<EOF
API_URL=http://localhost:8171
ENCRYPTION_KEY=$(openssl rand -hex 32)
WEBHOOK_SECRET_KEY=$(openssl rand -hex 32)
APP_SECRET_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/automatisch/.env
umask 022
ls -l ~/selfhost/automatisch/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same everywhere. Upstream's warning about the first two is worth repeating: change them and
existing connections and flows stop working. `ENCRYPTION_KEY` belongs in a password manager
tonight, read with `grep ENCRYPTION_KEY ~/selfhost/automatisch/.env`. On Windows those mode bits
are advisory: NTFS ignores them, and the user's own account is the boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/automatisch/compose.yml <<'EOF'
# Automatisch · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   installation ....... https://automatisch.io/docs/guide/installation
#   variable reference . https://automatisch.io/docs/advanced/configuration
#   url resolution ..... https://github.com/automatisch/automatisch/blob/v0.15.0/packages/backend/src/config/app.js
#
# Four services on the computer you are sitting at. One image runs twice: as the
# web and API process, and with WORKER=true as the queue worker, the split
# upstream documents for Docker. Two named volumes rather than bind mounts,
# because PostgreSQL and the Redis entrypoint both chown their data directory and
# Docker Desktop's Windows file sharing grants neither on a home-directory bind
# mount. API_URL is http://localhost:8171, this computer only; config/app.js
# builds the API base, the web app URL and the webhook URL from it. Digests read
# 2026-08-07; all images publish arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# Both app services share an image; compose ignores x- keys.
x-automatisch-env: &automatisch-env
  APP_ENV: production
  POSTGRES_HOST: postgres
  POSTGRES_DATABASE: automatisch
  POSTGRES_USERNAME: automatisch
  REDIS_HOST: redis
  # No seeded admin: the first account is made on the installation screen.
  DISABLE_SEED_USER: "true"
  TELEMETRY_ENABLED: "false"

x-automatisch: &automatisch
  image: ghcr.io/automatisch/automatisch:0.15.0@sha256:3bace7a12d5fb3f5b1305a6a52232270e0e0abd8465a8b78baacb07f6ea89594
  restart: unless-stopped
  env_file: ./.env
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: automatisch-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: automatisch
      POSTGRES_USER: automatisch
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - automatisch-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U automatisch -d automatisch"]
      interval: 10s
      retries: 12

  redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    container_name: automatisch-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - automatisch-redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  automatisch:
    <<: *automatisch
    container_name: automatisch
    environment: *automatisch-env
    ports:
      - "127.0.0.1:8171:3000"

  worker:
    <<: *automatisch
    container_name: automatisch-worker
    environment:
      # This copy runs the queue, not the web process.
      <<: *automatisch-env
      WORKER: "true"

volumes:
  automatisch-pgdata:
  automatisch-redisdata:
EOF
cd ~/selfhost/automatisch && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Four services, one published port, two named volumes.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule: no hostname to resolve, no public name for a
certificate to attest, nothing beyond loopback to close. Browsers treat http://localhost as a
secure context, so pages needing crypto still work.

8171 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not anyone on the
internet, not a third party calling a webhook. Confirm it:

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

Assert: that prints `1`, the single published port line. PostgreSQL and Redis publish no host
port, so 5432 and 6379 cannot appear.

## 7. Start and verify

The main container migrates the database on the way up, so first boot is slow.

```bash
cd ~/selfhost/automatisch
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8171/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8171/internal/api/v1/automatisch/version
docker compose logs --tail 20 worker | grep -c 'Workers are ready'
```

Assert all three, and print what you received for each: the loop ends on `200`; the version
response contains `"version":"0.15.0"`; the worker count is `1`. If any miss, stop, run
`docker compose logs --tail 40 automatisch`, and name the cause: a database that never reports
healthy points at step 4, where an empty `POSTGRES_PASSWORD` leaves PostgreSQL refusing to start;
a log still in migrations wants time; `port is already allocated` means something else holds
8171, so stop until the user frees it. A running container is not success.

The first screen is at http://localhost:8171/installation, and http://localhost:8171/ redirects
to it while the instance has no account. It shows the heading `Installation` over a form asking
for a full name, an email and a password twice, above a button reading `Create admin`.

STOP: tell the user to open http://localhost:8171/ and fill that form in, and wait.
Do not continue until they confirm. Then confirm the door shut:

```bash
curl -sS http://localhost:8171/internal/api/v1/automatisch/info | grep -o '"installationCompleted":true'
```

Assert: that prints `"installationCompleted":true`, which is a fresh instance saying it now has
an owner. That endpoint answers `403` from here on, and `DISABLE_SEED_USER` means the account
upstream's entrypoint seeds by default never existed. Both asserts must pass.

## 8. First backup and restore

Two artifacts: a database dump with the flows, connections and run history, and a config archive
with the two files that rebuild the service around it.

```bash
cd ~/selfhost/automatisch
docker compose exec -T postgres pg_dump -U automatisch -d automatisch | gzip > ~/selfhost/automatisch/backups/automatisch-db-$(date +%F).sql.gz
tar -C ~/selfhost/automatisch -czf ~/selfhost/automatisch/backups/automatisch-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/automatisch/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently. Both archives sit on the same disk as the data, which
is not a backup, and on a laptop disk and 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 both there with `cp`. Assert: the user
confirms both filenames are listed there. If neither is, say plainly that there is no backup.

To restore: `cd ~/selfhost/automatisch`, untar the config archive there first so .env is back
before any container starts, `docker compose down -v`, which drops the old volumes,
`docker compose up -d postgres`, wait 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz`
into `docker compose exec -T postgres psql -U automatisch -d automatisch`, run that same psql
command with `-c "UPDATE flows SET active = false;"`, then `docker compose up -d`. That last
statement is the step people miss: a published flow's schedule lives in Redis, so a restored
database describes flows nothing runs; clearing the flag lets the user switch each back on, which
re-registers it. Restored without the matching `ENCRYPTION_KEY`, the database comes back with
every flow intact and every credential unreadable.

## 9. Updating later

New versions are listed at https://github.com/automatisch/automatisch/releases. Back up first,
then edit the image line in ~/selfhost/automatisch/compose.yml to the new tag and digest.

```bash
cd ~/selfhost/automatisch
docker compose pull
docker compose up -d
docker compose logs --tail 30 automatisch
```

Watch it until it settles, then re-run step 7's checks.

## 10. What will probably go wrong

I closed the lid, came back next morning, and found an empty executions page on a flow I had
published the night before. Nothing was broken. Docker Desktop had not restarted with the
session, so nothing was listening on 8171, and once it was, Automatisch pins every polling
trigger to a fifteen-minute cron without an enterprise licence, so the first run was another
quarter of an hour off. Turn on Docker Desktop's start-at-login setting, then after a reboot run
`cd ~/selfhost/automatisch && docker compose up -d` and look for `Workers are ready!`.

## 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 change `API_URL` to this machine's LAN address and do not rebind 8171 to 0.0.0.0 so a
  phone or a webhook can reach it. That puts an engine holding every credential the user has
  connected onto every network they join.
- Do not configure SMTP, set `LICENSE_KEY`, or enable SAML, roles, templates or the public REST
  API under /api/v1. Those read files marked `.ee.`, on a separate licence.
- Do not connect a third-party app yet. Each connector needs its own developer registration.
````

## docker-compose.yml

```yaml
# Automatisch · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installation ....... https://automatisch.io/docs/guide/installation
#   variable reference . https://automatisch.io/docs/advanced/configuration
#   credentials ........ https://automatisch.io/docs/advanced/credentials
#   url resolution ..... https://github.com/automatisch/automatisch/blob/v0.15.0/packages/backend/src/config/app.js
#
# Four services. One image runs twice: as the web and API process, and with
# WORKER=true as the queue worker, the split upstream documents for Docker.
# PostgreSQL holds the flows, the connections and the run history; Redis holds
# the BullMQ queues and the schedule of every published flow. Upstream's own
# compose file builds from a git checkout; this one runs the image their release
# workflow publishes to ghcr.io.
#
# API_URL is the one address variable that matters: config/app.js derives the
# API base, the web app URL and the webhook URL from it, where the documented
# HOST and PORT pair would build https://host:3000 and break the app icons in
# the editor. Digests read 2026-08-07; all images publish arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# The web process and the worker share an image; compose ignores x- keys.
x-automatisch-env: &automatisch-env
  APP_ENV: production
  POSTGRES_HOST: postgres
  POSTGRES_DATABASE: automatisch
  POSTGRES_USERNAME: automatisch
  REDIS_HOST: redis
  # No seeded admin: the first account is typed on the installation screen.
  DISABLE_SEED_USER: "true"
  TELEMETRY_ENABLED: "false"

x-automatisch: &automatisch
  image: ghcr.io/automatisch/automatisch:0.15.0@sha256:3bace7a12d5fb3f5b1305a6a52232270e0e0abd8465a8b78baacb07f6ea89594
  restart: unless-stopped
  env_file: /srv/automatisch/.env
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: automatisch-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: automatisch
      POSTGRES_USER: automatisch
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/automatisch/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U automatisch -d automatisch"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    container_name: automatisch-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/automatisch/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  automatisch:
    <<: *automatisch
    container_name: automatisch
    environment: *automatisch-env
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8171.
      - "127.0.0.1:8171:3000"

  worker:
    <<: *automatisch
    container_name: automatisch-worker
    environment:
      <<: *automatisch-env
      # The one difference: this copy runs the queue, not the web process.
      WORKER: "true"
```

## compose.local.yml

```yaml
# Automatisch · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   installation ....... https://automatisch.io/docs/guide/installation
#   variable reference . https://automatisch.io/docs/advanced/configuration
#   url resolution ..... https://github.com/automatisch/automatisch/blob/v0.15.0/packages/backend/src/config/app.js
#
# Four services on the computer you are sitting at. One image runs twice: as the
# web and API process, and with WORKER=true as the queue worker, the split
# upstream documents for Docker. Two named volumes rather than bind mounts,
# because PostgreSQL and the Redis entrypoint both chown their data directory and
# Docker Desktop's Windows file sharing grants neither on a home-directory bind
# mount. API_URL is http://localhost:8171, this computer only; config/app.js
# builds the API base, the web app URL and the webhook URL from it. Digests read
# 2026-08-07; all images publish arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# Both app services share an image; compose ignores x- keys.
x-automatisch-env: &automatisch-env
  APP_ENV: production
  POSTGRES_HOST: postgres
  POSTGRES_DATABASE: automatisch
  POSTGRES_USERNAME: automatisch
  REDIS_HOST: redis
  # No seeded admin: the first account is made on the installation screen.
  DISABLE_SEED_USER: "true"
  TELEMETRY_ENABLED: "false"

x-automatisch: &automatisch
  image: ghcr.io/automatisch/automatisch:0.15.0@sha256:3bace7a12d5fb3f5b1305a6a52232270e0e0abd8465a8b78baacb07f6ea89594
  restart: unless-stopped
  env_file: ./.env
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: automatisch-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: automatisch
      POSTGRES_USER: automatisch
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - automatisch-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U automatisch -d automatisch"]
      interval: 10s
      retries: 12

  redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    container_name: automatisch-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - automatisch-redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  automatisch:
    <<: *automatisch
    container_name: automatisch
    environment: *automatisch-env
    ports:
      - "127.0.0.1:8171:3000"

  worker:
    <<: *automatisch
    container_name: automatisch-worker
    environment:
      # This copy runs the queue, not the web process.
      <<: *automatisch-env
      WORKER: "true"

volumes:
  automatisch-pgdata:
  automatisch-redisdata:
```

## Caddyfile

```text
# Automatisch · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://automatisch.io/docs/guide/installation 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 API_URL in .env, and every webhook address Automatisch hands a third
# party is built from it, so it is the value here you cannot change once
# published flows are running. The app sends its own frame headers, so there is
# no frame directive below.

<DOMAIN> {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting an app sends the user out to a third-party
		# consent screen and back, and some providers check the origin.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8171 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8171
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Automatisch · 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=flows.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://automatisch.io/docs/guide/installation
#   https://automatisch.io/docs/advanced/configuration
#   https://automatisch.io/docs/advanced/credentials
#   https://github.com/automatisch/automatisch/blob/v0.15.0/packages/backend/src/config/app.js
#
# Four secrets are generated here, on this machine: the credential encryption
# key, the webhook secret key, the app secret key and the PostgreSQL password.
# All four go into /srv/automatisch/.env with mode 600 and none is ever printed.
#
# DOMAIN_HOST is also API_URL, the hostname every webhook address this instance
# hands a third party is built from. Choose it once; flows already published
# keep pointing at the old name if you move it.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/automatisch}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

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. flows.example.com"
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 2048 ] || die "only ${avail_mb} MB of RAM available; two Node processes plus PostgreSQL and Redis want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 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 ----------------------------------------------------

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

# --- 3. Generate the four secrets, on the server -----------------------------
#
# Hex rather than base64 for all four: one of them rides inside a database
# connection string. Upstream's warning is worth repeating here, where the
# values are made: ENCRYPTION_KEY and WEBHOOK_SECRET_KEY encrypt third-party
# credentials and verify webhook requests, and changing either stops existing
# connections and flows from working. Read them later with
#   sudo grep -E 'ENCRYPTION_KEY|WEBHOOK_SECRET_KEY' /srv/automatisch/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		API_URL=https://${DOMAIN_HOST}
		ENCRYPTION_KEY=$(openssl rand -hex 32)
		WEBHOOK_SECRET_KEY=$(openssl rand -hex 32)
		APP_SECRET_KEY=$(openssl rand -hex 32)
		POSTGRES_PASSWORD=$(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-automatisch"
	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 8171, 5432 and 6379 are not among them ----------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8171, 5432 and 6379 stay 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 main container runs the database migrations on the way up, so the first
# boot takes minutes and Caddy answers 502 through most of them. The worker
# starts alongside it and says so in its own log.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/healthcheck (this takes minutes on a first boot)"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/healthcheck" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/healthcheck answered ${code:-nothing}. Check: docker compose logs --tail 40 automatisch"

curl -sS "https://${DOMAIN_HOST}/internal/api/v1/automatisch/version" | grep -q '"version":"0.15.0"' \
	|| die "the version endpoint did not report 0.15.0. Something other than the pinned image is answering."

# Nobody has registered yet: installationCompleted stays false until the first
# admin is created on the installation screen.
if curl -sS "https://${DOMAIN_HOST}/internal/api/v1/automatisch/info" | grep -q '"installationCompleted":true'; then
	die "this instance already has an owner. Stop and work out whose account it is before going further."
fi

docker compose logs --tail 20 worker | grep -q 'Workers are ready' \
	|| die "the worker never reported ready, so nothing you publish would run. Check: docker compose logs --tail 40 worker"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U automatisch -d automatisch | gzip > "$APP_DIR/backups/automatisch-db-${STAMP}.sql.gz"
sudo tar -C "$APP_DIR" -czf "$APP_DIR/backups/automatisch-config-${STAMP}.tar.gz" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/automatisch-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Automatisch is answering at https://${DOMAIN_HOST}/healthcheck

	  1. Open https://${DOMAIN_HOST}/ and create the first account. The screen
	     says "Installation" and the button says "Create admin". Once that
	     account exists the endpoint behind it answers 403 to everyone, and no
	     default account was ever seeded on this box.
	     Confirm it took:
	       curl -sS https://${DOMAIN_HOST}/internal/api/v1/automatisch/info | grep -o '"installationCompleted":true'
	  2. Your encryption key is in $APP_DIR/.env, mode 600. Read it with
	       sudo grep ENCRYPTION_KEY $APP_DIR/.env
	     and put it in your password manager. It was not printed here. Every
	     credential you connect is encrypted with it, and a database restored
	     without it comes back unreadable.
	  3. Each app you connect needs its own developer registration in that
	     company's portal, done from the Apps screen after you log in. Nothing
	     here is pre-registered, and 39 of the connectors ask for a redirect URL
	     you paste into somebody else's console.
	  4. Polling triggers run on a fifteen-minute cron without an enterprise
	     licence, so the first run of a flow you publish can be a quarter of an
	     hour away. That is the software, not a broken install.
	  5. First backup written to $APP_DIR/backups: a database dump and a config
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight, and keep the pair together.

DONE
```

## Also evaluated

Ranked below Automatisch for this swap. The prompts above install Automatisch only.

- **Activepieces** — Trigger-and-action automation with a visual builder, run on your own box, with no task meter. The bigger, faster-moving sibling, one page over as this catalog's Zapier answer. Pick it if active weekly development and a larger piece library matter more to you than the Make-shaped canvas; what you give up is the flow-per-line plainness that makes Automatisch feel like the product this page is named after.

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