---
title: Deploy on VPS
description: Install the full Reloop stack on a fresh Ubuntu or Debian server with one command.
icon: server
---
> For the complete documentation index, see [llms-docs.txt](/llms-docs.txt) or the site index [llms.txt](/llms.txt). Full docs corpus: [llms-full-docs.txt](/llms-full-docs.txt). Prefer markdown URLs (append `.md`) for agent consumption. Product skill: [skill.md](/skill.md).


The Reloop self-host installer takes a fresh VPS and turns it into a running Reloop deployment: it installs Docker, generates production secrets, writes the Compose stack, applies the database schema, starts every service, verifies it is healthy, and prints the DNS records you need to add.

```bash
curl -fsSL https://reloop.sh/install.sh | sudo bash
```

`https://reloop.sh/install.sh` is the canonical address. It serves
[`install/install.sh`](https://github.com/reloop-labs/reloop/blob/main/install/install.sh)
from the Reloop repository, and the script downloads the rest of the installer
(`install/lib` and `install/templates`) from the same place. To read it before
running it as root:

```bash
curl -fsSL https://reloop.sh/install.sh -o install.sh
less install.sh
sudo bash install.sh
```

---

## Before you start

| Requirement | Value |
| :--- | :--- |
| **Operating system** | Ubuntu 22.04 LTS, Ubuntu 24.04 LTS, Debian 12, Debian 13 |
| **Architecture** | x86_64 |
| **CPU** | 2 vCPUs minimum, 4+ recommended |
| **RAM** | 4 GB minimum, 8 GB recommended |
| **Disk** | 35 GB minimum, 50 GB recommended |
| **Ports** | `25`, `465`, `587` free, plus `80` and `443` unless you run [behind your own reverse proxy](#behind-an-existing-reverse-proxy) |
| **Access** | root or sudo |

> [!NOTE]
> The container images are roughly 22 GB in total, which is why the disk floor is higher than for a source install. The installer refuses to continue on a smaller disk rather than failing halfway through the download.

Many providers block outbound port `25` by default. Reloop still sends through the submission ports, but inbound mail needs `25` open in both directions. Ask your provider to unblock it if you plan to receive mail.

---

## What the installer asks

| Prompt | Purpose | Default |
| :--- | :--- | :--- |
| Primary Reloop domain | The hostname the dashboard and API are served on | - |
| TLS contact email | Let's Encrypt expiry and account notices | - |
| Database name | PostgreSQL database | `reloop` |
| Database user | PostgreSQL role | `reloop` |
| Run behind an existing reverse proxy | Serve plain HTTP on a local port for your own Caddy, Nginx or Traefik instead of taking `80` and `443`. See [Behind an existing reverse proxy](#behind-an-existing-reverse-proxy) | `no` |
| Local port for your reverse proxy | Only asked behind a proxy. Reloop listens on `127.0.0.1` at this port | `8080` |
| Configure automatic HTTPS | Let Caddy obtain and renew certificates. Behind your own proxy it asks whether that proxy serves Reloop over HTTPS instead | `yes` |
| Configure object storage | S3-compatible storage for file uploads | `no` |

Everything else is generated: database and Redis passwords, the Better Auth secret, the internal service secret, the tracking and preferences secrets, and the webhook encryption key. They are written to `/opt/reloop/.env` with mode `0600`.

Enter the domain as a bare hostname: `reloop.example.com`, not `https://reloop.example.com` and not `reloop.example.com/admin`.

---

## What gets installed

```text
/opt/reloop/
├── .env                 production configuration and secrets (0600, root)
├── admin-setup.key      one-time first-run setup key (0600, emptied after setup)
├── docker-compose.yml   the production stack
├── Caddyfile            reverse proxy and TLS
└── backups/             automatic database dumps taken before schema changes
```

The stack runs 20 containers: 14 Reloop backend services, 2 frontends (dashboard, links), PostgreSQL, Redis, NATS and Caddy. Only Caddy (`80` and `443`, or `127.0.0.1:<port>` behind your own proxy) and the two mail transports (`25` inbound, `465`/`587` submission) publish host ports. The database and event bus stay on the internal Docker network.

A self-hosted install deliberately leaves out the parts of Reloop that only make sense for the hosted service: the public documentation site (`/docs` redirects to reloop.sh), the credits and quota service, the public email-validation tools, the operator console, and bundled object storage. Sending is not metered.

The installer writes `SELF_HOSTED=true` to `.env`, which turns off every plan limit: sending quotas, the inbox, domain and organization caps, and dedicated IP entitlements. Installations from before this setting existed need it added by hand: put `SELF_HOSTED=true` in `/opt/reloop/.env` and run `reloop restart`.

A `reloop` command is installed alongside it:

```bash
reloop status            # container health
reloop logs [service]    # follow logs
reloop restart [service] # recreate the stack or one service, applying .env changes
reloop update            # pull the configured version and redeploy
```

---

## File uploads

Reloop stores template images and attachments in S3-compatible object storage. The installer does not bundle a storage server. Point it at whichever provider you already use (AWS S3, Cloudflare R2, Hetzner Object Storage, Backblaze B2, a MinIO box of your own).

Answer yes to the storage prompt and supply the endpoint, access key, secret key, bucket and region. The `upload` service is then deployed alongside the rest of the stack.

Answer no and file uploads stay off. To turn them on later, set the `S3_*` values and `COMPOSE_PROFILES=storage` in `/opt/reloop/.env`, then run `reloop restart`.

> [!NOTE]
> The bucket must serve objects publicly over HTTP. Reloop hands out file URLs as `{S3_ENDPOINT}/{S3_BUCKET}/{path}`, so anything the dashboard uploads has to be readable at that address.

---

## DNS

When the installer finishes it prints the records for the installation itself:

```text
reloop.example.com            A    203.0.113.10
link.reloop.example.com       A    203.0.113.10
inbound.reloop.example.com    A    203.0.113.10
reloop.example.com            TXT  "v=spf1 ip4:203.0.113.10 -all"
```

- The apex record serves the dashboard, the API and the Let's Encrypt HTTP challenge.
- `link.` serves click and open tracking plus the unsubscribe pages, and is the CNAME target for customer tracking domains.
- `inbound.` is the MX target for mail your verified domains receive.
- The SPF record authorises the server to send for the host domain, which is what the `include:` in each sending domain's SPF resolves to.

These are separate from the SPF, DKIM, DMARC and MX records for the domains you send from. Those are generated per domain by Reloop. Add a domain under **Domains** in the dashboard and it shows the exact records. See [Connect a domain](/docs/guides/connect-domain). **Auto populate** only works on Reloop Cloud, because the Domain Connect template is tied to it, so add these records by hand with [manual setup](/docs/guides/connect-domain/manual-setup).

You can run the installer before adding DNS. Caddy keeps retrying certificate issuance in the background and HTTPS starts working on its own once the records resolve. Follow it with `reloop logs proxy`.

---

## Behind an existing reverse proxy

Use this when the server already runs Caddy, Nginx or Traefik on `80` and
`443`, for example alongside other sites. Answer yes to **Run behind an
existing reverse proxy**, or set `RELOOP_EXTERNAL_PROXY=true` for an
unattended install. The installer then:

- publishes the bundled Caddy on `127.0.0.1:<port>` only, over plain HTTP, and no longer needs `80` or `443`
- keeps routing every Reloop path itself, so your proxy forwards whole hostnames
- trusts `X-Forwarded-For` and `X-Forwarded-Proto` from private addresses, so Reloop sees the visitor's IP and the `https` scheme

Answer yes to **Does your proxy serve Reloop over HTTPS** if it does. That sets
the `https://` URLs Reloop puts in links and emails.

To move the port later, change `RELOOP_PROXY_PORT` in `/opt/reloop/.env` and
run `reloop restart proxy`.

### What your proxy needs to do

| Hostname | Forward to | TLS certificate |
| :--- | :--- | :--- |
| `reloop.example.com` | `http://127.0.0.1:8080` | Issued and renewed by your proxy |
| `link.reloop.example.com` | `http://127.0.0.1:8080` | Issued and renewed by your proxy |
| Customer tracking domains | `http://127.0.0.1:8080` | Issued on demand, see below |

- **Keep the original `Host` header.** Reloop decides between the dashboard and the tracking site by hostname.
- **Pass WebSocket upgrades.** Live email logs, the inbox and template collaboration use them.
- **Set `X-Forwarded-For` and `X-Forwarded-Proto`.**

`inbound.reloop.example.com` needs no proxy route. It is only an MX target,
and Reloop serves port `25` directly.

**Custom tracking domains.** A customer can point their own hostname, such as
`track.customer.com`, at `link.reloop.example.com` with a CNAME, and it needs a
certificate as soon as it is added. Reloop answers Caddy's on-demand TLS check
at `https://reloop.example.com/api/domain/v1/caddy/ask?domain=<hostname>`,
returning `200` for an active tracking domain and `404` otherwise, so Caddy
issues these automatically. Nginx and Traefik have no equivalent: add a route
and certificate for each tracking domain yourself, or don't offer custom
tracking domains.

### Caddy

```caddyfile
{
	on_demand_tls {
		ask https://reloop.example.com/api/domain/v1/caddy/ask
	}
}

reloop.example.com, link.reloop.example.com {
	reverse_proxy 127.0.0.1:8080
}

https:// {
	tls {
		on_demand
	}
	reverse_proxy 127.0.0.1:8080
}
```

Merge `on_demand_tls` into your existing global options block. The `https://`
block catches every hostname your other sites don't claim, which is what lets
tracking domains work; if you already have a catch-all site, route tracking
domains from there instead. Caddy keeps the `Host` header, sets the forwarded
headers and passes WebSockets without extra configuration, and renews its
certificates itself.

### Nginx

```nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name reloop.example.com link.reloop.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name reloop.example.com link.reloop.example.com;

    ssl_certificate     /etc/letsencrypt/live/reloop.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/reloop.example.com/privkey.pem;

    client_max_body_size 25m;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}
```

The certificate must cover both hostnames, for example
`certbot certonly --nginx -d reloop.example.com -d link.reloop.example.com`.
Certbot's timer renews it. `client_max_body_size` raises Nginx's 1 MB default:
uploads and email attachments go up to 10 MB, and attachments grow by about a
third when base64-encoded in an API request.

### Traefik

Traefik usually runs in a container, where `127.0.0.1` is the Traefik container
itself. Put Traefik on Reloop's Docker network and forward to the Reloop proxy
container by name. In Traefik's compose file:

```yaml
services:
  traefik:
    networks:
      - reloop

networks:
  reloop:
    external: true
```

Then add a router and service, here with the file provider:

```yaml
http:
  routers:
    reloop:
      rule: Host(`reloop.example.com`) || Host(`link.reloop.example.com`)
      entryPoints:
        - websecure
      service: reloop
      tls:
        certResolver: letsencrypt
  services:
    reloop:
      loadBalancer:
        servers:
          - url: http://reloop-proxy:80
```

Replace `websecure` and `letsencrypt` with your own entry point and certificate
resolver. Traefik passes the `Host` header and WebSockets by default and renews
certificates through the resolver.

The same applies to Caddy or Nginx running in a container: join the `reloop`
network and forward to `http://reloop-proxy:80` instead of `127.0.0.1`.

---

## TLS certificates and mail ports

| Hostname or port | With the bundled Caddy | Behind your own proxy |
| :--- | :--- | :--- |
| `reloop.example.com` | Let's Encrypt, issued and renewed by Caddy | Your proxy |
| `link.reloop.example.com` | Let's Encrypt, issued and renewed by Caddy | Your proxy |
| Customer tracking domains | Issued on demand and renewed by Caddy | Caddy on demand, or by hand |
| Port `25`, `587`, `465` | Self-signed certificate, see below | Same |

The mail ports are published by Docker directly and never pass through the
HTTP proxy, in either setup.

| Port | Used by | Protocol |
| :--- | :--- | :--- |
| `25` | Other mail servers delivering to your verified domains | SMTP with STARTTLS |
| `587` | Your applications submitting mail to Reloop | SMTP submission with STARTTLS |
| `465` | Your applications submitting mail to Reloop | The same STARTTLS listener as `587`, not implicit TLS |

The SMTP services generate a self-signed certificate, since no certificate is
mounted into them. Most sending servers delivering to port `25` accept that
for opportunistic TLS. For submission, use port `587` with STARTTLS: a client set to
implicit TLS (often labelled SSL/TLS or SMTPS) on `465` will fail, and a client
that verifies certificates has to be told to accept this one.

---

## First sign-in

The installer prints a one-time administrator setup key when a new installation
finishes. It also saves the key to `/opt/reloop/admin-setup.key`, a root-only
file, in case you need to retrieve it before completing setup.

1. Open `https://your-domain/dashboard/setup`
2. Enter the setup key
3. Choose the first administrator's name, email and password
4. Name the instance and first organization
5. Leave **Turn off public sign-ups** selected for a private instance, or clear
   it if anyone should be able to register

The first account is created as a `super-admin`. Completing setup consumes the
key, empties `admin-setup.key`, sets `SETUP_MODE=false` and closes the setup
page. The installer only prints the key once.

To supply your own key, download the installer before running it:

```bash
curl -fsSL https://reloop.sh/install.sh -o install.sh
sudo bash install.sh --admin-key='your-long-random-key'
```

`RELOOP_ADMIN_EMAIL` is only the TLS contact address; it does not preselect or
create the administrator account. Self-host setup uses a password, not an
emailed code. Reloop Cloud's one-time-code sign-in is unrelated and unchanged.

If the setup flow is unavailable after an account already exists,
`apps/backend/admin/scripts/promote-admin.ts` remains available as a
break-glass recovery tool. It is not the normal first-run setup path.

To change registration or organization creation later, edit these values in
`/opt/reloop/.env` and run `reloop restart auth`:

| Variable | Effect |
| :--- | :--- |
| `DISABLE_SIGNUP=true` | No new accounts on any sign-up path. Existing users still sign in, and invited addresses can still join |
| `DISABLE_ORG_CREATION=true` | Nobody can create further organizations |

The installer keeps both values across re-runs, so an update will not reopen
registration. See [Closing registration](/docs/setup/backend/auth#closing-registration).

If sign-in succeeds but the dashboard then answers `401` on every protected
page, the services cannot reach the auth service over your public origin. Set
`AUTH_INTERNAL_BASE_URL=http://auth:8000` in `/opt/reloop/.env` and run
`reloop restart`. See [session validation between
services](/docs/setup/backend/auth#session-validation-between-services).

---

## Configuration reference

Everything lives in `/opt/reloop/.env`. The installer writes it and keeps your
edits across re-runs. After changing a value, run `reloop restart`, or
`reloop restart <service>` for one service.

<Warning>
The `reloop` command from earlier installers ran a plain `docker compose
restart`, which does not re-read `.env`. If `reloop restart` does not pick up a
change, run `reloop start` instead, or re-run the installer to update the
command.
</Warning>

| Variable | Purpose |
| :--- | :--- |
| `RELOOP_VERSION` | Image tag deployed for every service |
| `RELOOP_DOMAIN` | Dashboard and API hostname |
| `RELOOP_TRACKING_HOST` / `RELOOP_INBOUND_HOST` | `link.` and `inbound.` hostnames, derived from `RELOOP_DOMAIN` |
| `RELOOP_ADMIN_EMAIL` | Let's Encrypt contact address only |
| `RELOOP_PUBLIC_IP` | Address printed in the DNS records and SPF |
| `RELOOP_HTTPS` | Whether Reloop is reached over `https://` |
| `RELOOP_EXTERNAL_PROXY` / `RELOOP_PROXY_PORT` | Run behind your own proxy, and the local port it forwards to |
| `BASE_URL` / `TRACKING_BASE_URL` | Public origins used in links and emails, generated from the values above |
| `RELOOP_SITE_ADDRESS` / `RELOOP_TRACKING_SITE_ADDRESS` | Site addresses for the bundled Caddy, generated |
| `POSTGRES_*`, `REDIS_PASSWORD`, `BETTER_AUTH_SECRET`, `RELOOP_INTERNAL_SECRET`, `TRACKING_SECRET`, `PREFERENCES_SECRET`, `WEBHOOK_ENCRYPTION_KEY` | Generated once and kept across re-runs. Back them up with the file and don't replace them by hand |
| `SETUP_MODE` / `ADMIN_SETUP_KEY_FILE` | First-run setup state and the setup key path inside the auth container |
| `DISABLE_SIGNUP` / `DISABLE_ORG_CREATION` | Close registration and organization creation |
| `AUTH_INTERNAL_BASE_URL` | Internal origin for session checks between services |
| `APP_NAME` | Name used in system email. See [Branding system email](/docs/setup/backend/email#branding-system-email) |
| `RELOOP_API_KEY`, `RELOOP_SENDER_DOMAIN`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SECURE` | How system email is delivered. See [How system email is delivered](/docs/setup/backend/email#how-system-email-is-delivered) |
| `SMTP_HOSTNAME` | Hostname senders connect to for submission, shown in the dashboard |
| `COMPOSE_PROFILES`, `S3_*` | File uploads, see [File uploads](#file-uploads) |
| `DNS_RESOLVERS` | Resolvers used to verify DKIM, SPF, DMARC and MX records |

Per-service variables are documented under [Backend services](/docs/setup/backend/auth).

---

## Running it again

Re-running the installer is safe. It detects the existing installation and offers to abort, reconfigure (re-ask the domain and HTTPS settings while keeping data and secrets), or redeploy with the existing configuration. Secrets, volumes and the database are never regenerated or dropped, and a database dump is taken before any schema change.

---

## Updating and rolling back

```bash
reloop update
```

This pulls the images for `RELOOP_VERSION`, dumps the database to
`/opt/reloop/backups/pre-migration-<timestamp>.sql.gz`, applies the schema and
recreates the containers that changed. It does not touch
`docker-compose.yml`, the `Caddyfile` or the `reloop` command. To pick up
changes to those as well, re-run the install command and choose to redeploy with
the existing configuration.

Reloop images are published as `latest`, and not every image has a per-release
tag yet, so rolling back to an earlier image version is not supported. What you
can roll back is the database, to the dump taken before an update:

```bash
cd /opt/reloop
ls -t backups/
reloop stop
docker compose up -d postgres
docker compose exec -T postgres psql -U reloop -d reloop \
  -c 'drop schema public cascade; create schema public authorization reloop'
gunzip -c backups/pre-migration-20260917101500.sql.gz |
  docker compose exec -T postgres psql -U reloop -d reloop -v ON_ERROR_STOP=1
reloop start
```

Run these as root, use the file name `ls` shows, and replace `reloop` after
`-U` and `-d` if you changed the database user or name. This discards everything
written since the dump was taken.

---

## Unattended installs

Every prompt has an environment variable, so the installer can run without a terminal:

```bash
curl -fsSL https://reloop.sh/install.sh | sudo \
  RELOOP_NONINTERACTIVE=true \
  RELOOP_DOMAIN=reloop.example.com \
  RELOOP_ADMIN_EMAIL=admin@example.com \
  RELOOP_PUBLIC_IP=203.0.113.10 \
  bash
```

| Variable | Purpose |
| :--- | :--- |
| `RELOOP_NONINTERACTIVE` | Never prompt; fail if a required value is missing |
| `RELOOP_DOMAIN` | Primary hostname |
| `RELOOP_ADMIN_EMAIL` | TLS contact email |
| `RELOOP_ADMIN_SETUP_KEY` | Optional administrator setup key override; equivalent to `--admin-key=` |
| `RELOOP_DB_NAME` / `RELOOP_DB_USER` | Database name and role |
| `RELOOP_HTTPS` | `yes` or `no`. Behind your own proxy, whether that proxy serves HTTPS |
| `RELOOP_EXTERNAL_PROXY` | `yes` to run behind your own reverse proxy instead of taking `80` and `443` |
| `RELOOP_PROXY_PORT` | Local port your proxy forwards to (default `8080`) |
| `RELOOP_S3` | `yes` to configure object storage, `no` to disable file uploads |
| `RELOOP_S3_ENDPOINT` / `RELOOP_S3_ACCESS_KEY` / `RELOOP_S3_SECRET_KEY` | Object storage credentials |
| `RELOOP_S3_BUCKET` / `RELOOP_S3_REGION` | Bucket and region |
| `RELOOP_PUBLIC_IP` | Skip public address detection |
| `RELOOP_VERSION` | Image tag to deploy (default `latest`) |
| `RELOOP_EXISTING` | `abort`, `reconfigure` or `continue` for a re-run |
| `RELOOP_INSTALL_DIR` | Installation directory (default `/opt/reloop`) |
