# Can I self-host Zapier?

**YES** — it's called Activepieces. ONE EVENING setup · ~1.8 hours to running · 4 GB RAM minimum · $29.99/mo you stop paying ($359.88/yr on the Professional plan) — a metered rate, not a whole bill.

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

## 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 Activepieces 0.86.3-hotfix.1 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 `AP_FRONTEND_URL`, and every webhook URL this
instance hands out is built from it, so moving it later breaks flows already running. Its A
record must already point at this server.

Activepieces needs 4096 MB of RAM available and 20 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 4096 MB or free disk is under 20 GB, print both numbers and stop. Do
not install and hope. Upstream sizes a combined API-and-worker container at roughly 1 GB per
concurrent flow on top of a 2 GB API tier. If `dig +short` prints nothing, print that and stop.

## 2. Layout

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

Assert: `ls -la` shows `backups` and `cache` 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 yourself makes PostgreSQL refuse to
initialise. `cache` holds downloaded piece packages, which is a cache and not user data.

## 3. Secrets

Three secrets: the encryption key that protects stored connection credentials, the JWT signing
secret, and the PostgreSQL password. Generate all three 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. Upstream documents
the encryption key as 32 hexadecimal characters, which is `-hex 16`; that length is not
optional.

```bash
umask 077
cat > /srv/activepieces/.env <<EOF
AP_FRONTEND_URL=https://<DOMAIN>
AP_ENCRYPTION_KEY=$(openssl rand -hex 16)
AP_JWT_SECRET=$(openssl rand -hex 32)
AP_POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/activepieces/.env
umask 022
ls -l /srv/activepieces/.env
```

Assert: the file exists with mode `-rw-------`. Tell the user what the encryption key is for:
every credential they hand a piece is encrypted with it and unreadable without it. It belongs
in their password manager tonight, read with
`sudo grep AP_ENCRYPTION_KEY /srv/activepieces/.env`.

## 4. compose.yml

```bash
cat > /srv/activepieces/compose.yml <<'EOF'
# Activepieces · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose install ..... https://www.activepieces.com/docs/install/options/docker-compose
#   variable reference .. https://www.activepieces.com/docs/install/reference/environment-variables
#   sizing and sandbox .. https://www.activepieces.com/docs/install/configure-operate/production-setup
#
# Three services: Activepieces, the PostgreSQL that holds the flows and the run
# history, and the Redis that holds the job queue. Upstream's own compose file
# splits the API and five worker replicas apart; this one does not, because
# AP_CONTAINER_TYPE defaults to WORKER_AND_APP and one image runs both roles.
# PostgreSQL is the pgvector image because the knowledge base asks the database
# for that extension at every boot and drops the feature when it is absent.
# Upstream's compose pins pgvector 0.8.0-pg14; this file runs the pg16 line,
# the major upstream's own CI runs its Postgres suite against.
#
# Neither the database nor the queue declares `ports:`, and 8095 binds to
# loopback, so the host's Caddy is the only thing that reaches this stack.
# AP_EXECUTION_MODE is upstream's choice for a single-tenant install and the
# image default: flow code runs with this container's own reach.
# AP_WORKER_CONCURRENCY is 2, roughly 1 GB per concurrent flow on top of the API
# tier, and the queue dashboard stays off because it stops the boot when it is on
# with no credentials set. Digests read 2026-08-05; all three images publish
# amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg16@sha256:a36250871de0833b8757561c72f2477ef1ddd1101afa4e617fb552e0de514c6b
    container_name: activepieces-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: activepieces
      POSTGRES_USER: activepieces
      POSTGRES_PASSWORD: ${AP_POSTGRES_PASSWORD}
    volumes:
      - /srv/activepieces/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U activepieces -d activepieces"]
      interval: 10s
      retries: 12

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

  activepieces:
    image: ghcr.io/activepieces/activepieces:0.86.3-hotfix.1@sha256:4da6910cf46dbc38857c8c4fac6ba867ab804b8a3a8551d672d4490cb1245566
    container_name: activepieces
    restart: unless-stopped
    env_file: /srv/activepieces/.env
    environment:
      AP_ENVIRONMENT: prod
      AP_CONTAINER_TYPE: WORKER_AND_APP
      AP_DB_TYPE: POSTGRES
      AP_POSTGRES_HOST: postgres
      AP_POSTGRES_PORT: "5432"
      AP_POSTGRES_DATABASE: activepieces
      AP_POSTGRES_USERNAME: activepieces
      AP_REDIS_TYPE: STANDALONE
      AP_REDIS_HOST: redis
      AP_REDIS_PORT: "6379"
      AP_EXECUTION_MODE: UNSANDBOXED
      AP_WORKER_CONCURRENCY: "2"
      AP_QUEUE_UI_ENABLED: "false"
      AP_TELEMETRY_ENABLED: "false"
    volumes:
      - /srv/activepieces/cache:/usr/src/app/cache
    ports:
      - "127.0.0.1:8095:80"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
EOF
cd /srv/activepieces && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, one published port, and neither the database
nor the queue publishes anything. `${AP_POSTGRES_PASSWORD}` comes from the `.env` step 3 wrote,
which compose reads from the project directory.

## 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-activepieces
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Activepieces · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.activepieces.com/docs/install/configure-operate/setup-ssl 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
# AP_FRONTEND_URL in .env, and every webhook URL this instance hands out is built
# from it, so it is the value here you cannot change once flows are running.

<DOMAIN> {
	# The flow editor holds a websocket open for live run output. Upstream's proxy
	# example passes the upgrade headers by hand; Caddy does it without being told.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting a piece sends the user out to a third-party
		# OAuth consent screen and back, and some providers check the origin.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8095 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:8095
}
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-activepieces, 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 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. 8095 stays closed because it is bound to 127.0.0.1, and 5432 and 6379 stay closed
because compose never publishes them at all. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no rule for 8095, 5432 or 6379.

## 7. Start and verify

Activepieces runs its own migrations on the way up, then syncs the piece catalogue metadata, so
the first boot is slow. The image's health check waits 60 seconds before it asks.

```bash
cd /srv/activepieces
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>/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/flags | grep -o '"USER_CREATED":[a-z]*' || echo "USER_CREATED absent"
```

Assert, all three, and print what you received for each. The loop ends on `200`. The health
response is exactly `{"status":"Healthy"}`. The flags call prints `USER_CREATED absent`, which
is how a fresh instance says nobody has registered yet. If any of the three misses, stop, run
`docker compose logs --tail 40 activepieces` and `docker compose logs --tail 20 postgres`, and
name the likely cause: a `502` past ten minutes points at step 4, a database that never reports
healthy points at step 2, a certificate error at step 5. A running container is not success.

The first screen is at https://<DOMAIN>/sign-up and shows the heading `Create a new account`
over a form asking for a first name, a last name, an email and a password.

STOP: tell the user to open https://<DOMAIN>/sign-up, create the first account, and wait. Do
not continue until they confirm. That account owns the instance. Then confirm registration has
closed behind them:

```bash
curl -sS https://<DOMAIN>/api/v1/flags | grep -o '"USER_CREATED":true'
```

Assert: that prints `"USER_CREATED":true`. From here a second sign-up is answered against the
platform the first account created, and that path requires an invitation unless
`AP_ALLOW_OPEN_SIGN_UP` is set, which this install never sets. Both asserts must pass.

## 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/activepieces
docker compose exec -T postgres pg_dump -U activepieces -d activepieces | gzip > /srv/activepieces/backups/activepieces-db-$(date +%F).sql.gz
sudo tar -C /srv/activepieces -czf /srv/activepieces/backups/activepieces-config-$(date +%F).tar.gz compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/activepieces/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 not backed up and does not need
to be: it carries jobs in flight, not the record of what the flows are.

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

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

To restore: `docker compose down`, `sudo rm -rf /srv/activepieces/postgres`, recreate it as in
step 2, untar the config archive into /srv/activepieces so `.env` is back before anything
starts, `docker compose up -d postgres`, wait for it to report healthy, pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T postgres psql -U activepieces -d activepieces`, then
`docker compose up -d`. Tell the user the stake: a database restored without the matching
`AP_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/activepieces/activepieces/releases. Take both
backups first, then edit the image line in /srv/activepieces/compose.yml to the new tag and
digest:

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

Activepieces migrates its own database on the way up, so watch that log until it settles, then
re-run step 7's health check before calling the update done.

## 10. What will probably go wrong

The first boot looks like a failed install for several minutes. I watched Caddy answer `502`
over and over while the container ran migrations and pulled the piece catalogue metadata, and
the pull to start editing the compose file was strong. Nothing was wrong. The image's health
check does not begin probing for 60 seconds, and step 7's loop waits ten minutes for a reason.
Before touching anything, run `docker compose logs --tail 40 activepieces`: a log still moving
is an install still working.

## 11. Out of scope

- Do not configure SMTP. No `AP_SMTP_` variable is set here, and the community edition verifies
  the first account itself rather than mailing a link.
- Do not set `AP_GOOGLE_CLIENT_ID`, `AP_GOOGLE_CLIENT_SECRET` or any SSO variable. First login
  is an email address and a password, and single sign-on is a paid-edition feature.
- Do not split the worker into its own container, raise `AP_WORKER_CONCURRENCY`, or configure
  S3 storage. Those are the production shape for a fleet, and this is one machine.
- Do not set `AP_NETWORK_MODE=STRICT` or change `AP_EXECUTION_MODE`. Upstream states that the
  strict guard is best-effort inside the process, not a boundary against hostile code.
````

## 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 Activepieces 0.86.3-hotfix.1 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 `AP_FRONTEND_URL`, and every webhook URL and OAuth
redirect this instance hands out is built from it. Flows that are already running keep pointing
at the old name if you move it, so 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 `4096` 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. On memory, the floor is
real rather than cautious: upstream sizes a container running the API and a worker together at
roughly 1 GB per concurrent flow on top of a 2 GB API tier, and this install runs two flows at
once. A 2 GB box will start, then be killed by the kernel partway through your first real flow.

## 2. Layout

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

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

If you do not: leave those last two owned by root on purpose. Both images chown their own data
directory the first time they start, and a PostgreSQL directory you have already chowned to
yourself makes the container refuse to initialise. `cache` holds the piece packages the app
downloads; it is a cache, and losing it costs a slow boot and nothing else.

## 3. Secrets

Three secrets: the encryption key that protects every credential you will hand a piece, the JWT
signing secret, and the PostgreSQL password. All three are generated here, on the server, into
a file only you can read. The encryption key is 32 hexadecimal characters because upstream says
so, which is why it is `-hex 16` while the other two are `-hex 32`.

```bash
umask 077
cat > /srv/activepieces/.env <<EOF
AP_FRONTEND_URL=https://<DOMAIN>
AP_ENCRYPTION_KEY=$(openssl rand -hex 16)
AP_JWT_SECRET=$(openssl rand -hex 32)
AP_POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/activepieces/.env
umask 022
ls -l /srv/activepieces/.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.

Do not paste that file, any of the three values, or any command output containing them into
this chat window. The agent path never sees them; this one will hand them to a third party
unless you are deliberate about 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/activepieces/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
all three secrets, which is fine before the database exists and a problem afterwards:
PostgreSQL keeps the password it was created with, so a changed `AP_POSTGRES_PASSWORD` on an
existing data directory shows up as an authentication failure in the activepieces log rather
than anything about passwords. Read the encryption key once with
`sudo grep AP_ENCRYPTION_KEY /srv/activepieces/.env` and put it in your password manager: every
connection you create is encrypted with it, and a restored database without it is a list of
flows you cannot run.

## 4. compose.yml

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

```bash
cat > /srv/activepieces/compose.yml <<'EOF'
# Activepieces · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose install ..... https://www.activepieces.com/docs/install/options/docker-compose
#   variable reference .. https://www.activepieces.com/docs/install/reference/environment-variables
#   sizing and sandbox .. https://www.activepieces.com/docs/install/configure-operate/production-setup
#
# Three services: Activepieces, the PostgreSQL that holds the flows and the run
# history, and the Redis that holds the job queue. Upstream's own compose file
# splits the API and five worker replicas apart; this one does not, because
# AP_CONTAINER_TYPE defaults to WORKER_AND_APP and one image runs both roles.
# PostgreSQL is the pgvector image because the knowledge base asks the database
# for that extension at every boot and drops the feature when it is absent.
# Upstream's compose pins pgvector 0.8.0-pg14; this file runs the pg16 line,
# the major upstream's own CI runs its Postgres suite against.
#
# Neither the database nor the queue declares `ports:`, and 8095 binds to
# loopback, so the host's Caddy is the only thing that reaches this stack.
# AP_EXECUTION_MODE is upstream's choice for a single-tenant install and the
# image default: flow code runs with this container's own reach.
# AP_WORKER_CONCURRENCY is 2, roughly 1 GB per concurrent flow on top of the API
# tier, and the queue dashboard stays off because it stops the boot when it is on
# with no credentials set. Digests read 2026-08-05; all three images publish
# amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg16@sha256:a36250871de0833b8757561c72f2477ef1ddd1101afa4e617fb552e0de514c6b
    container_name: activepieces-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: activepieces
      POSTGRES_USER: activepieces
      POSTGRES_PASSWORD: ${AP_POSTGRES_PASSWORD}
    volumes:
      - /srv/activepieces/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U activepieces -d activepieces"]
      interval: 10s
      retries: 12

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

  activepieces:
    image: ghcr.io/activepieces/activepieces:0.86.3-hotfix.1@sha256:4da6910cf46dbc38857c8c4fac6ba867ab804b8a3a8551d672d4490cb1245566
    container_name: activepieces
    restart: unless-stopped
    env_file: /srv/activepieces/.env
    environment:
      AP_ENVIRONMENT: prod
      AP_CONTAINER_TYPE: WORKER_AND_APP
      AP_DB_TYPE: POSTGRES
      AP_POSTGRES_HOST: postgres
      AP_POSTGRES_PORT: "5432"
      AP_POSTGRES_DATABASE: activepieces
      AP_POSTGRES_USERNAME: activepieces
      AP_REDIS_TYPE: STANDALONE
      AP_REDIS_HOST: redis
      AP_REDIS_PORT: "6379"
      AP_EXECUTION_MODE: UNSANDBOXED
      AP_WORKER_CONCURRENCY: "2"
      AP_QUEUE_UI_ENABLED: "false"
      AP_TELEMETRY_ENABLED: "false"
    volumes:
      - /srv/activepieces/cache:/usr/src/app/cache
    ports:
      - "127.0.0.1:8095:80"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
EOF
cd /srv/activepieces && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/activepieces/.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/activepieces/compose.yml` and paste again in one go. The `${AP_POSTGRES_PASSWORD}`
on the postgres service is not a typo; compose reads it out of the `.env` in the same
directory, which is why that file has to exist before this command runs.

## 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-activepieces
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Activepieces · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.activepieces.com/docs/install/configure-operate/setup-ssl 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
# AP_FRONTEND_URL in .env, and every webhook URL this instance hands out is built
# from it, so it is the value here you cannot change once flows are running.

<DOMAIN> {
	# The flow editor holds a websocket open for live run output. Upstream's proxy
	# example passes the upgrade headers by hand; Caddy does it without being told.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting a piece sends the user out to a third-party
		# OAuth consent screen and back, and some providers check the origin.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8095 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:8095
}
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-activepieces /etc/caddy/Caddyfile`,
reload, and paste again. The flow editor streams live run output over a websocket, and
upstream's own proxy example sets the upgrade headers by hand. Caddy does that without being
told, so there is no upgrade stanza in the block and nothing missing from it.

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

If you do not: delete anything for those three with `sudo ufw delete allow 8095`. 8095 is bound
to 127.0.0.1 by the compose file, and 5432 and 6379 are never published at all, so neither the
database nor the queue has a host 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 any further.

## 7. Start and verify

Activepieces runs its own database migrations on the way up and then syncs the metadata for the
piece catalogue. The first boot is slow and the loop below is built to wait for it.

```bash
cd /srv/activepieces
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>/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/flags | grep -o '"USER_CREATED":[a-z]*' || echo "USER_CREATED absent"
```

You should see, in order: the loop climbing through `502` and then reaching `200`, then
`{"status":"Healthy"}`, then `USER_CREATED absent`.

If you do not: the `502` while it climbs is normal, because Caddy has a site block for a
container that is still migrating. The image's own health check does not begin probing for 60
seconds. If the loop never reaches `200`, run `docker compose logs --tail 20 postgres` first,
because a database that never reports healthy is step 2 done wrong, and
`docker compose logs --tail 40 activepieces` second. A certificate error rather than a `502`
points at step 5 or at DNS.

Now open https://<DOMAIN>/sign-up in a browser. The first screen shows the heading
`Create a new account` over a form asking for a first name, a last name, an email address and a
password. Create the account. It owns this instance.

Then confirm registration has closed behind you:

```bash
curl -sS https://<DOMAIN>/api/v1/flags | grep -o '"USER_CREATED":true'
```

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

If you do not: an empty result means the sign-up did not complete, so reload the page and check
whether you are logged in. Once that flag is true, a second sign-up is answered against the
platform your account created, and that path requires an invitation unless
`AP_ALLOW_OPEN_SIGN_UP` is set, which this install never sets. Both of those checks passing is
what success means here. A running container is not success.

## 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, including the encryption key.

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

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently. Redis is not in the backup and does not
need to be, because it carries jobs in flight rather than the record of what the flows are.

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

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

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/activepieces
docker compose down
sudo rm -rf /srv/activepieces/postgres
sudo install -d -m 700 /srv/activepieces/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/activepieces/backups/activepieces-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U activepieces -d activepieces
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/api/v1/health
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `{"status":"Healthy"}`.

If you do not: `role "activepieces" does not exist` means the database container had not
finished initialising, so wait longer and run the `gunzip` line again. Understand the stake
before you skip this step. The dump and the config archive are one backup in two files:
restored without the matching `AP_ENCRYPTION_KEY` from `.env`, that database comes back with
every flow intact and every stored credential unreadable, and there is no recovery from that
other than reconnecting every service by hand.

## 9. Updating later

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

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

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 flow in the editor as
well, because a service that answers `Healthy` can still be failing to run flows if a migration
stopped halfway.

## 10. What will probably go wrong

The first boot looks like a failed install for several minutes. I watched Caddy answer `502`
over and over while the container ran migrations and pulled the piece catalogue metadata, and
the pull to start editing the compose file was strong. Nothing was wrong. The image's health
check does not begin probing for 60 seconds, and the loop in step 7 waits ten minutes for a
reason. Before touching anything, run `docker compose logs --tail 40 activepieces`: a log still
moving is an install still working.

## 11. Out of scope

- Do not configure SMTP. No `AP_SMTP_` variable is set here, and the community edition verifies
  the first account itself rather than mailing a link.
- Do not set `AP_GOOGLE_CLIENT_ID`, `AP_GOOGLE_CLIENT_SECRET` or any SSO variable. First login
  is an email address and a password, and single sign-on is a paid-edition feature.
- Do not split the worker into its own container, raise `AP_WORKER_CONCURRENCY`, or configure
  S3 storage. Those are the production shape for a fleet, and this is one machine.
- Do not set `AP_NETWORK_MODE=STRICT` or change `AP_EXECUTION_MODE`. Upstream states that the
  strict guard is best-effort inside the process, not a boundary against hostile code.
````

## 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 Activepieces 0.86.3-hotfix.1, with its PostgreSQL and Redis, under
~/selfhost/activepieces, answering at http://localhost:8095.

## 1. Preflight

Say this before step 2 runs; it decides whether the user wants this install. Schedules and
polling triggers work, while the machine is awake. Webhook triggers do not: the URL handed to a
third party starts http://localhost:8095, which nothing outside this computer can deliver to.

Detect the OS and measure:

```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 4096 MB of RAM
available and 20 GB free on the home disk; all three images publish amd64 and arm64. On macOS
and Windows that figure is the host's, out of which Docker Desktop takes its share. Under
either floor, print both numbers 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/activepieces/backups ~/selfhost/activepieces/cache
ls -la ~/selfhost/activepieces
```

Assert: `ls -la` shows `backups` and `cache`, both owned by the user. There is no `data`
folder: flows and queue live in volumes Docker manages.

## 4. Secrets

Three secrets: the encryption key protecting stored connection credentials, the JWT signing
secret, and the PostgreSQL password. Generate all three here, print none, and keep them out of
your summary and any log line. Upstream documents the encryption key as 32 hex characters,
`-hex 16`; that length is not optional.

```bash
umask 077
cat > ~/selfhost/activepieces/.env <<EOF
AP_FRONTEND_URL=http://localhost:8095
AP_ENCRYPTION_KEY=$(openssl rand -hex 16)
AP_JWT_SECRET=$(openssl rand -hex 32)
AP_POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/activepieces/.env
umask 022
ls -l ~/selfhost/activepieces/.env
```

Assert: the file exists with mode `-rw-------`; Git Bash ships openssl, so this runs the same
everywhere. Every credential the user hands a piece is encrypted with that key, so tell them to
put it in their password manager tonight, read with
`grep AP_ENCRYPTION_KEY ~/selfhost/activepieces/.env`. On Windows those mode bits are advisory
and the real boundary is the user's own account.

## 5. compose.yml

```bash
cat > ~/selfhost/activepieces/compose.yml <<'EOF'
# Activepieces · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   compose install ..... https://www.activepieces.com/docs/install/options/docker-compose
#   variable reference .. https://www.activepieces.com/docs/install/reference/environment-variables
#   sizing and sandbox .. https://www.activepieces.com/docs/install/configure-operate/production-setup
#
# Three services, every path relative to ~/selfhost/activepieces/ so one file
# works on macOS, Linux and Windows. One image runs the API and the worker;
# PostgreSQL is the pgvector image because the knowledge base asks for that
# extension at boot. AP_EXECUTION_MODE is upstream's choice for single-tenant and
# the image default: flow code runs with this container's reach, home network
# included. 8095 binds to loopback and AP_FRONTEND_URL is http://localhost:8095,
# this computer only. Digests read 2026-08-05; all three publish amd64 and arm64.
#
# Two named volumes rather than bind mounts: PostgreSQL chowns its data directory
# to its own uid and Redis chowns /data to the redis user, and Docker Desktop's
# Windows file sharing grants neither on a home-directory bind mount. The piece
# cache stays a relative bind, visible in Finder or Explorer.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg16@sha256:a36250871de0833b8757561c72f2477ef1ddd1101afa4e617fb552e0de514c6b
    container_name: activepieces-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: activepieces
      POSTGRES_USER: activepieces
      POSTGRES_PASSWORD: ${AP_POSTGRES_PASSWORD}
    volumes:
      - activepieces-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U activepieces -d activepieces"]
      interval: 10s
      retries: 12

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

  activepieces:
    image: ghcr.io/activepieces/activepieces:0.86.3-hotfix.1@sha256:4da6910cf46dbc38857c8c4fac6ba867ab804b8a3a8551d672d4490cb1245566
    container_name: activepieces
    restart: unless-stopped
    env_file: ./.env
    environment:
      AP_ENVIRONMENT: prod
      AP_CONTAINER_TYPE: WORKER_AND_APP
      AP_DB_TYPE: POSTGRES
      AP_POSTGRES_HOST: postgres
      AP_POSTGRES_PORT: "5432"
      AP_POSTGRES_DATABASE: activepieces
      AP_POSTGRES_USERNAME: activepieces
      AP_REDIS_TYPE: STANDALONE
      AP_REDIS_HOST: redis
      AP_REDIS_PORT: "6379"
      AP_EXECUTION_MODE: UNSANDBOXED
      AP_WORKER_CONCURRENCY: "1"
      AP_QUEUE_UI_ENABLED: "false"
      AP_TELEMETRY_ENABLED: "false"
    volumes:
      - ./cache:/usr/src/app/cache
    ports:
      - "127.0.0.1:8095:80"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

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

Assert: that prints `compose OK`.

## 6. Nothing is public

Three absences, each a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8095 is bound to 127.0.0.1: not the user's phone, not a laptop on the same wifi, not anyone on
the internet. That is the point of this path, and why a webhook has nowhere to arrive.

```bash
grep -n '127.0.0.1' ~/selfhost/activepieces/compose.yml
```

Assert: one line, `- "127.0.0.1:8095:80"`.

## 7. Start and verify

Activepieces runs its migrations, then syncs the piece catalogue metadata, so first boot is
slow. The image's health check waits 60 seconds before it asks.

```bash
cd ~/selfhost/activepieces
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:8095/api/v1/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8095/api/v1/health
curl -sS http://localhost:8095/api/v1/flags | grep -o '"USER_CREATED":[a-z]*' || echo "USER_CREATED absent"
```

Assert all three, and print what you received for each: the loop ends on `200`; the health
response is exactly `{"status":"Healthy"}`; the flags call prints `USER_CREATED absent`, which
is how a fresh instance says nobody has registered. If any misses, stop, run
`docker compose logs --tail 40 activepieces` and `docker compose logs --tail 20 postgres`, and
name the likely cause: a database that never reports healthy points at step 4. If
`port is already allocated` came back, find what holds 8095 with
`lsof -nP -iTCP:8095 -sTCP:LISTEN` and stop until it is free. A running container is not
success.

The first screen is at http://localhost:8095/sign-up and shows the heading
`Create a new account` over a form asking for a first name, a last name, an email and a
password.

STOP: tell the user to open that URL, create the first account, and wait. Do not continue until
they confirm. That account owns the instance. Then confirm registration closed:

```bash
curl -sS http://localhost:8095/api/v1/flags | grep -o '"USER_CREATED":true'
```

Assert: that prints `"USER_CREATED":true`. A second sign-up is now answered against the
platform the first account created, and that path needs an invitation unless
`AP_ALLOW_OPEN_SIGN_UP` is set, which this install never does. 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, encryption key included.

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

Assert: both files exist and are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently. Redis is not backed up: it carries jobs in flight,
not the flows.

Both archives sit on the same disk as the data, and on a laptop disk and machine fail together.
Ask for a destination that leaves this computer, a sync folder or a USB stick, and copy both
there with `cp`; in Git Bash a Windows drive is `/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. `cd ~/selfhost/activepieces` and untar the config archive there first, so
compose.yml and .env are back before any container starts: PostgreSQL reads
`AP_POSTGRES_PASSWORD` from .env the moment it initialises an empty volume. Then
`docker compose down -v`, the one place `-v` belongs, `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 activepieces -d activepieces`, then
`docker compose up -d` and re-run step 7's health check. Restored without the matching
`AP_ENCRYPTION_KEY`, that database comes back with every flow intact and nothing able to run.

## 9. Updating later

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

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

Watch that log until it settles, then re-run the health check.

## 10. What will probably go wrong

I closed the lid on a Friday and came back Monday to a flow that had not run since. Nothing
crashed: a scheduled trigger fires only while this computer is awake and Docker is up, and a
sleeping laptop is neither. `restart: unless-stopped` acts only once the daemon is running, so
a reboot without Docker Desktop's start-at-login setting leaves nothing on 8095. Turn that
setting on, and after a reboot run `cd ~/selfhost/activepieces && docker compose up -d` before
concluding anything is wrong.

## 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 install a tunnel to make webhook triggers reachable, and do not rebind 8095 to
  0.0.0.0. Either puts a machine holding every credential the user connected onto a network.
- Do not configure SMTP, `AP_GOOGLE_CLIENT_ID`, or any SSO variable. First login is an email
  address and a password; SSO is a paid-edition feature.
- Do not change `AP_EXECUTION_MODE` or `AP_NETWORK_MODE`. Upstream states the strict guard is
  best-effort in-process, not a boundary against hostile code.
````

## docker-compose.yml

```yaml
# Activepieces · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose install ..... https://www.activepieces.com/docs/install/options/docker-compose
#   variable reference .. https://www.activepieces.com/docs/install/reference/environment-variables
#   sizing and sandbox .. https://www.activepieces.com/docs/install/configure-operate/production-setup
#
# Three services: Activepieces, the PostgreSQL that holds the flows and the run
# history, and the Redis that holds the job queue. Upstream's own compose file
# splits the API and five worker replicas apart; this one does not, because
# AP_CONTAINER_TYPE defaults to WORKER_AND_APP and one image runs both roles.
# PostgreSQL is the pgvector image because the knowledge base asks the database
# for that extension at every boot and drops the feature when it is absent.
# Upstream's compose pins pgvector 0.8.0-pg14; this file runs the pg16 line,
# the major upstream's own CI runs its Postgres suite against.
#
# Neither the database nor the queue declares `ports:`, and 8095 binds to
# loopback, so the host's Caddy is the only thing that reaches this stack.
# AP_EXECUTION_MODE is upstream's choice for a single-tenant install and the
# image default: flow code runs with this container's own reach.
# AP_WORKER_CONCURRENCY is 2, roughly 1 GB per concurrent flow on top of the API
# tier, and the queue dashboard stays off because it stops the boot when it is on
# with no credentials set. Digests read 2026-08-05; all three images publish
# amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg16@sha256:a36250871de0833b8757561c72f2477ef1ddd1101afa4e617fb552e0de514c6b
    container_name: activepieces-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: activepieces
      POSTGRES_USER: activepieces
      POSTGRES_PASSWORD: ${AP_POSTGRES_PASSWORD}
    volumes:
      - /srv/activepieces/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U activepieces -d activepieces"]
      interval: 10s
      retries: 12

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

  activepieces:
    image: ghcr.io/activepieces/activepieces:0.86.3-hotfix.1@sha256:4da6910cf46dbc38857c8c4fac6ba867ab804b8a3a8551d672d4490cb1245566
    container_name: activepieces
    restart: unless-stopped
    env_file: /srv/activepieces/.env
    environment:
      AP_ENVIRONMENT: prod
      AP_CONTAINER_TYPE: WORKER_AND_APP
      AP_DB_TYPE: POSTGRES
      AP_POSTGRES_HOST: postgres
      AP_POSTGRES_PORT: "5432"
      AP_POSTGRES_DATABASE: activepieces
      AP_POSTGRES_USERNAME: activepieces
      AP_REDIS_TYPE: STANDALONE
      AP_REDIS_HOST: redis
      AP_REDIS_PORT: "6379"
      AP_EXECUTION_MODE: UNSANDBOXED
      AP_WORKER_CONCURRENCY: "2"
      AP_QUEUE_UI_ENABLED: "false"
      AP_TELEMETRY_ENABLED: "false"
    volumes:
      - /srv/activepieces/cache:/usr/src/app/cache
    ports:
      - "127.0.0.1:8095:80"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Activepieces · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   compose install ..... https://www.activepieces.com/docs/install/options/docker-compose
#   variable reference .. https://www.activepieces.com/docs/install/reference/environment-variables
#   sizing and sandbox .. https://www.activepieces.com/docs/install/configure-operate/production-setup
#
# Three services, every path relative to ~/selfhost/activepieces/ so one file
# works on macOS, Linux and Windows. One image runs the API and the worker;
# PostgreSQL is the pgvector image because the knowledge base asks for that
# extension at boot. AP_EXECUTION_MODE is upstream's choice for single-tenant and
# the image default: flow code runs with this container's reach, home network
# included. 8095 binds to loopback and AP_FRONTEND_URL is http://localhost:8095,
# this computer only. Digests read 2026-08-05; all three publish amd64 and arm64.
#
# Two named volumes rather than bind mounts: PostgreSQL chowns its data directory
# to its own uid and Redis chowns /data to the redis user, and Docker Desktop's
# Windows file sharing grants neither on a home-directory bind mount. The piece
# cache stays a relative bind, visible in Finder or Explorer.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg16@sha256:a36250871de0833b8757561c72f2477ef1ddd1101afa4e617fb552e0de514c6b
    container_name: activepieces-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: activepieces
      POSTGRES_USER: activepieces
      POSTGRES_PASSWORD: ${AP_POSTGRES_PASSWORD}
    volumes:
      - activepieces-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U activepieces -d activepieces"]
      interval: 10s
      retries: 12

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

  activepieces:
    image: ghcr.io/activepieces/activepieces:0.86.3-hotfix.1@sha256:4da6910cf46dbc38857c8c4fac6ba867ab804b8a3a8551d672d4490cb1245566
    container_name: activepieces
    restart: unless-stopped
    env_file: ./.env
    environment:
      AP_ENVIRONMENT: prod
      AP_CONTAINER_TYPE: WORKER_AND_APP
      AP_DB_TYPE: POSTGRES
      AP_POSTGRES_HOST: postgres
      AP_POSTGRES_PORT: "5432"
      AP_POSTGRES_DATABASE: activepieces
      AP_POSTGRES_USERNAME: activepieces
      AP_REDIS_TYPE: STANDALONE
      AP_REDIS_HOST: redis
      AP_REDIS_PORT: "6379"
      AP_EXECUTION_MODE: UNSANDBOXED
      AP_WORKER_CONCURRENCY: "1"
      AP_QUEUE_UI_ENABLED: "false"
      AP_TELEMETRY_ENABLED: "false"
    volumes:
      - ./cache:/usr/src/app/cache
    ports:
      - "127.0.0.1:8095:80"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  activepieces-pgdata:
  activepieces-redisdata:
```

## Caddyfile

```text
# Activepieces · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.activepieces.com/docs/install/configure-operate/setup-ssl 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
# AP_FRONTEND_URL in .env, and every webhook URL this instance hands out is built
# from it, so it is the value here you cannot change once flows are running.

<DOMAIN> {
	# The flow editor holds a websocket open for live run output. Upstream's proxy
	# example passes the upgrade headers by hand; Caddy does it without being told.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting a piece sends the user out to a third-party
		# OAuth consent screen and back, and some providers check the origin.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8095 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:8095
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Activepieces · 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://www.activepieces.com/docs/install/options/docker-compose
#   https://www.activepieces.com/docs/install/reference/environment-variables
#   https://www.activepieces.com/docs/install/configure-operate/production-setup
#   https://www.activepieces.com/docs/install/configure-operate/setup-ssl
#
# Three secrets are generated here, on this machine: the encryption key, the JWT
# signing secret and the PostgreSQL password. All three go into
# /srv/activepieces/.env with mode 600 and none of them is ever printed.
#
# DOMAIN_HOST is also AP_FRONTEND_URL, the hostname every webhook URL and OAuth
# redirect is built from. Choose it once; flows already running 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/activepieces}"
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 4096 ] || die "only ${avail_mb} MB of RAM available; the API and two concurrent flows want 4096 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 ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/cache"
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 three secrets, on the server ----------------------------
#
# The encryption key is 32 hexadecimal characters because upstream documents that
# length, which is why it is -hex 16 while the other two are -hex 32. Read them
# later with
#   sudo grep -E 'AP_ENCRYPTION_KEY|AP_JWT_SECRET|AP_POSTGRES_PASSWORD' /srv/activepieces/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		AP_FRONTEND_URL=https://${DOMAIN_HOST}
		AP_ENCRYPTION_KEY=$(openssl rand -hex 16)
		AP_JWT_SECRET=$(openssl rand -hex 32)
		AP_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-activepieces"
	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 8095, 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; 8095, 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 -------------------------------------------------------------
#
# Activepieces runs its own database migrations on the way up and then syncs the
# metadata for the piece catalogue, so the first boot takes minutes and Caddy
# answers 502 for most of them.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/v1/health (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}/api/v1/health" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/api/v1/health answered ${code:-nothing}. Check: docker compose logs --tail 40 activepieces"

curl -sS "https://${DOMAIN_HOST}/api/v1/health" | grep -q '"status":"Healthy"' \
	|| die "/api/v1/health answered 200 without status Healthy. Check: docker compose logs --tail 40 activepieces"

# Nobody has registered yet: the flag that records the first sign-up is absent
# from the public flags document until an account exists.
if curl -sS "https://${DOMAIN_HOST}/api/v1/flags" | grep -q '"USER_CREATED":true'; then
	die "an account already exists on this instance. Stop and work out whose it is before going further."
fi

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

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

cat <<-DONE

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

	  1. Open https://${DOMAIN_HOST}/sign-up and create the first account. The
	     screen says "Create a new account". That account owns this instance, and
	     once it exists a second sign-up needs an invitation.
	     Confirm it took:
	       curl -sS https://${DOMAIN_HOST}/api/v1/flags | grep -o '"USER_CREATED":true'
	  2. Your encryption key is in $APP_DIR/.env, mode 600. Read it with
	       sudo grep AP_ENCRYPTION_KEY $APP_DIR/.env
	     and put it in your password manager. It was not printed here. Every
	     credential you hand a piece is encrypted with it, and a database restored
	     without it comes back unreadable.
	  3. Flow code runs unsandboxed, with this container's own network reach. The
	     flows you write are the security boundary; treat a flow from someone else
	     the way you would treat a shell script from someone else.
	  4. 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
```

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