# Run your own Frame server > Host Frame for you and your friends: one small open-source program and one data folder. What you need, which setup to pick, and where everything is documented. Source: https://crow.sg/apps/frame/docs/ Frame keeps your group's photos on a server you control: a small virtual server, a Raspberry Pi at home or an old laptop. The server is one small open-source program, `frame-server`, with one data folder and no outside services to sign up for. If you can copy commands into a terminal and edit a text file, you can run it. The server is open source under AGPL-3.0, on [GitHub](https://github.com/Crow-Tech-Pte-Ltd/frame). The iOS and Android apps are published by Crow Tech and connect to any Frame server: everyone types your server's address when they sign up. ## What you need - **A Linux machine that stays on.** A small virtual server, a home computer or a Raspberry Pi 3B+, 4, 5 or Zero 2 W. One CPU core and 512 MB to 1 GB of RAM are enough; Frame itself uses about 30–60 MB. - **Some disk.** Ten friends sending five photos a day need about 8 GB a year. Videos need more. Media can also go to an S3-compatible bucket such as Cloudflare R2 ([Storage & sizing](https://crow.sg/apps/frame/docs/storage/)). - **A domain name**, such as `frame.example.com`. The apps need HTTPS, and HTTPS needs a name. A subdomain of a domain you already own works and costs nothing extra. - **Docker with Compose** on an amd64 or arm64 machine (on a Raspberry Pi, a 64-bit OS), or **systemd** on an amd64, arm64 or armv7 machine for the Linux installer. ## Pick a setup | You have… | Use | HTTPS by | |---|---|---| | A small VPS and a domain, nothing else yet | [Linux installer with built-in HTTPS](https://crow.sg/apps/frame/docs/install-systemd/#vps) (also the [quickstart](https://crow.sg/apps/frame/docs/quickstart/)) | Frame itself (Let's Encrypt) | | A Raspberry Pi or home computer, and no open ports (or CGNAT) | [Cloudflare Tunnel with the installer](https://crow.sg/apps/frame/docs/install-systemd/#cloudflare-tunnel) or [with Docker](https://crow.sg/apps/frame/docs/install-docker/#cloudflare-tunnel) | Cloudflare | | A server where you already use Docker | [Docker Compose with Caddy](https://crow.sg/apps/frame/docs/install-docker/#caddy) | Caddy, automatically | | A server that already runs Caddy, nginx or another web server | [Behind your own proxy](https://crow.sg/apps/frame/docs/install-systemd/#reverse-proxy) | your proxy | Whichever you pick, you end up with an address like `frame.example.com` that you and your friends type into the Frame app. > [!NOTE] Always set `FRAME_PUBLIC_URL` > Set it to the address people type into the app, such as `FRAME_PUBLIC_URL=https://frame.example.com`. Sign in with Apple and Google only work when it is set, because each sign-in is bound to your server's name: that stops another Frame server from reusing a login token to sign in as you on yours. Frame also uses it in invite links. Every setup in these docs sets it. Reached under several names, say a public domain and a LAN name? List them all, separated by commas, with the main one first. ## How it fits together - **The apps** talk to your server over HTTPS. New servers are invite-only: on its first start the server prints a one-time link to create your admin account, and you invite friends from the admin panel or the app. - **The server** stores accounts, friendships, messages and moments in SQLite, and the photos and videos on its own disk or in an R2 bucket. It never decodes or re-encodes media, so even a Pi stays idle most of the time. - **The admin panel** at `/admin` manages users, reports, invites and settings. It never shows anyone's photos or messages, except a single item a member reports to you. Admins can also manage the server from the apps. - **Notifications** go through Crow Tech's content-blind relay at `frame.crow.sg`, because Apple and Google only deliver notifications signed with the app publisher's keys. The content is end-to-end encrypted between your server and each phone, and you can turn push off. ## Get the apps The apps are free, with no ads, and work with any Frame server. - **iPhone:** [Download on the App Store](https://apps.apple.com/app/id6832514449) - **Android:** Coming soon to Google Play - **Server:** [Free and open source · GitHub](https://github.com/Crow-Tech-Pte-Ltd/frame) ## Where to go next - [Quickstart](https://crow.sg/apps/frame/docs/quickstart/): A small server with your own domain, from nothing to your first invite. - [Set up with an AI agent](https://crow.sg/apps/frame/docs/ai-setup/): A ready-to-paste prompt that lets a coding agent set the server up with you. - [Docker Compose](https://crow.sg/apps/frame/docs/install-docker/): The official image behind Caddy, a Cloudflare Tunnel or your own proxy. - [Linux installer (systemd)](https://crow.sg/apps/frame/docs/install-systemd/): Built-in HTTPS on a VPS, a Pi behind a tunnel, or behind Caddy or nginx. - [Binary & source](https://crow.sg/apps/frame/docs/install-binary/): Run the release binary yourself, build from source, or try it locally. - [First run & admin account](https://crow.sg/apps/frame/docs/first-run/): The one-time setup link, your admin account and two-factor authentication. - [Connect the apps & invite](https://crow.sg/apps/frame/docs/connect/): Point the apps at your server, and decide who can join. - [Admin panel](https://crow.sg/apps/frame/docs/admin-panel/): Users, reports, invites and settings, on the web and in the apps. - [Keep the admin panel private](https://crow.sg/apps/frame/docs/private-admin/): A local-only panel over SSH, local mode, or no panel at all. - [Push notifications](https://crow.sg/apps/frame/docs/push/): How the content-blind relay delivers notifications, and how to turn it off. - [Storage & sizing](https://crow.sg/apps/frame/docs/storage/): Local disk or Cloudflare R2, and how much disk, bandwidth and money. - [Backups & restore](https://crow.sg/apps/frame/docs/backups/): What to back up, nightly backups, and restoring on a new machine. - [Upgrading](https://crow.sg/apps/frame/docs/upgrading/): Updating a systemd or Docker install, and hearing about releases. - [Configuration](https://crow.sg/apps/frame/docs/configuration/): Every frame.conf setting and the admin command line. - [Security](https://crow.sg/apps/frame/docs/security/): What Frame protects, what it doesn't, and a checklist for your server. - [Troubleshooting](https://crow.sg/apps/frame/docs/troubleshooting/): Fixes for connection, invite, HTTPS, upload and notification problems. The repository has the same material in its own words: the [self-hosting guide](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/docs/SELF-HOSTING.md), the [configuration reference](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/docs/CONFIG.md), the [admin panel](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/docs/ADMIN.md), the [security model](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/docs/SECURITY.md) and the [push protocol](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/docs/PUSH.md). > [!WARNING] Your friends trust you with their photos > Whoever runs a server can see what is stored on it. Keep the server updated and backed up, and only join servers run by people you trust. --- # Quickstart > Run Frame on a small Linux server with your own domain: point the domain, run the installer, set four lines of config, create your admin account and invite your friends. Source: https://crow.sg/apps/frame/docs/quickstart/ The simplest always-on setup: a small virtual server (a VPS) with Debian or Ubuntu and a domain name. Frame gets and renews its own HTTPS certificate, so you need no other web server. Allow about 15 minutes. Prefer Docker, or running at home without open ports? See [Pick a setup](https://crow.sg/apps/frame/docs/#pick-a-setup). ## Before you start - A VPS with **Debian 13** or **Ubuntu 24.04 LTS** and your SSH key on it. Any provider works; about €4–6 a month gets 2–4 GB of RAM, 40 GB or more of disk and plenty of traffic. - A domain you can add DNS records to. This guide uses `frame.example.com`: replace it with yours everywhere. - Commands that start with `sudo` run as the administrator. Never pipe a script from the internet straight into a shell: download it, read it, then run it. ## Set it up 1. **Point your domain at the server** At your DNS provider, add an `A` record named `frame` (or `@` for the bare domain) with the server's IPv4 address, and optionally an `AAAA` record with its IPv6 address. On Cloudflare DNS, set the record to **DNS only** (grey cloud). Check it: `dig +short frame.example.com` should print the server's IP. This can take a few minutes. 2. **Update the server and open the firewall** Allow SSH and web traffic, and nothing else. If your provider also has a firewall in its control panel, allow TCP 22, 80 and 443 there too. ```sh ssh root@frame.example.com # or the user your provider created sudo apt update && sudo apt upgrade -y sudo apt install -y ufw sudo ufw allow OpenSSH sudo ufw allow 80,443/tcp sudo ufw enable ``` 3. **Download the installer, read it, run it** ```sh curl -fsSLO https://github.com/Crow-Tech-Pte-Ltd/frame/releases/latest/download/install.sh less install.sh # read it (press q to quit) sudo sh install.sh ``` The installer picks the release for your CPU, checks its SHA-256 checksum against the release's `SHA256SUMS` before installing anything, and sets up a locked-down systemd service. It creates `/etc/frame/frame.conf` and never touches your data in `/var/lib/frame`. 4. **Configure it** Open the file with `sudo nano /etc/frame/frame.conf`. Every line is commented out: remove the `#` and fill in at least these four. Save with Ctrl+O, Enter, then exit with Ctrl+X. ```ini title="/etc/frame/frame.conf" FRAME_DOMAIN=frame.example.com FRAME_PUBLIC_URL=https://frame.example.com FRAME_ACME_EMAIL=you@example.com FRAME_NAME="Our Frame" ``` New servers are invite-only, so strangers who find the address can't sign up. Leave `FRAME_TRUSTED_PROXIES` unset: phones connect to Frame directly. 5. **Start it** ```sh sudo sh install.sh --reconfigure # lets Frame use ports 80/443 because FRAME_DOMAIN is set sudo systemctl start frame systemctl status frame # should say "active (running)" ``` The first HTTPS request fetches the certificate, which takes a few seconds. Then open `https://frame.example.com/healthz` in a browser: it should say `ok`. 6. **Create your admin account** On its first start, the server writes a one-time link to its log: ```sh sudo journalctl -u frame | grep "admin account" ``` ```text output Create your admin account: https://frame.example.com/admin/setup#t=… ``` Open it and choose your name, a username, your e-mail or phone number and a password. That is your normal Frame account and also the admin of the web panel at `https://frame.example.com/admin/`. Then open **Security** in the panel and turn on two-factor authentication. More in [First run & admin account](https://crow.sg/apps/frame/docs/first-run/). 7. **Connect the app and invite friends** In the Frame app, type `frame.example.com` when it asks **Where's your Frame?** and sign in with the same details. Invite friends from the app, or from **Invites → New invite** in the admin panel. See [Connect the apps & invite](https://crow.sg/apps/frame/docs/connect/). ## Next - [Back up](https://crow.sg/apps/frame/docs/backups/) the database and `secrets.json` every night, and copy them off the server. - Don't need the admin panel on the internet? [Keep it private](https://crow.sg/apps/frame/docs/private-admin/). - Read the [security checklist](https://crow.sg/apps/frame/docs/security/#checklist), and [upgrade](https://crow.sg/apps/frame/docs/upgrading/) when a release is announced. --- # Set up with an AI agent > Copy a ready-made prompt that lets an AI coding agent such as Claude Code or Codex set up your Frame server with you: install, HTTPS, the admin account, a private admin panel and backups. Source: https://crow.sg/apps/frame/docs/ai-setup/ If you use a coding agent in a terminal, it can do the setup with you. **Copy for my AI**, at the top of every docs page, copies the prompt below. Paste it into Claude Code, Codex, Gemini CLI or another agent that runs on your server, or on a computer that can reach it over SSH. ## What the agent does - Asks you first: where the server runs, your domain, Docker or the Linux installer, how private the admin panel should be, who can join, and where backups go. - Installs Frame the way these docs describe, reading the installer before it runs it, and checks HTTPS with `/healthz`. - Hands the one-time admin setup link to you: you create the admin account and turn on two-factor authentication yourself. - Optionally makes the admin panel private, sets up e-mail resets or R2, and schedules nightly backups. - Checks each step, and knows the common pitfalls from [Troubleshooting](https://crow.sg/apps/frame/docs/troubleshooting/). > [!WARNING] You stay in charge of secrets and dashboards > The agent runs commands with `sudo` on your server, so read what it proposes. Type tunnel tokens, SMTP passwords and R2 keys into the files yourself rather than into the chat, and make DNS and Cloudflare changes in your own browser. ## The prompt ````markdown You are helping me self-host a Frame server: the open-source server (AGPL-3.0, https://github.com/Crow-Tech-Pte-Ltd/frame) behind the Frame photo-sharing apps for iPhone and Android. Goal: `frame-server` running on my Linux machine over HTTPS at my own domain, my admin account created with two-factor authentication, the admin panel only as exposed as I want, the apps connected, and nightly backups that leave the machine. Work one step at a time and tell me what you are about to run before you run it. If what you see doesn't match these instructions, read the matching docs page (links at the end; add `.md` for Markdown) before you improvise. Don't invent settings: every `FRAME_*` setting is in the configuration reference. ## Before you start, ask me 1. Where will it run: a VPS with a public IP, a Raspberry Pi or home computer (no open ports), or a server that already runs Docker or a web server? Are you running on that machine, or do you reach it over SSH (which host and user)? 2. The domain or subdomain people will type into the app (for example `frame.example.com`), and who manages its DNS (Cloudflare or another provider). 3. Which setup: the Linux installer with systemd (built-in HTTPS, Cloudflare Tunnel, or behind an existing Caddy/nginx), or Docker Compose (with Caddy, a Cloudflare Tunnel, built-in HTTPS, or behind an existing proxy)? 4. An e-mail address for Let's Encrypt (built-in HTTPS only) and the server name to show in the app. 5. Should the admin panel stay on the internet at `/admin`, be reachable only over an SSH tunnel, or be off? Do I want Server admin in the apps anyway? 6. Who can join: invite-only (default), closed, or open? Do I want e-mail password resets (SMTP server details), and media on local disk or in Cloudflare R2? 7. Where should backups be copied to (my computer with `scp`, or a `restic`/`rclone` destination)? ## Prerequisites - Linux with systemd on amd64, arm64 or armv7 (installer), or Docker Engine with Compose on amd64 or arm64 (a 64-bit OS on a Pi). 1 CPU core and 512 MB–1 GB of RAM are enough. - A domain I can add DNS records to. The apps need HTTPS; they reject self-signed certificates. - Built-in HTTPS or Caddy: ports 80 and 443 reachable from the internet (plus 22 for SSH). Cloudflare Tunnel: the domain's DNS on Cloudflare, and no open ports needed. ## Steps 1. **DNS.** Built-in HTTPS or Caddy: an `A` record (and optionally `AAAA`) for the hostname pointing at the server, **DNS only** if it's on Cloudflare. Check with `dig +short frame.example.com`. Tunnel: I create the tunnel in the Cloudflare dashboard (Zero Trust → Networks → Tunnels) and keep the token secret. 2. **Firewall** (not for a tunnel): `sudo ufw allow OpenSSH`, `sudo ufw allow 80,443/tcp`, `sudo ufw enable`, and the provider's firewall too. 3. **Install.** Pick the matching path: - Linux installer: download, show me, then run it. Never pipe it into a shell. ```sh curl -fsSLO https://github.com/Crow-Tech-Pte-Ltd/frame/releases/latest/download/install.sh less install.sh sudo sh install.sh ``` Then edit `/etc/frame/frame.conf` (every line starts commented out). Built-in HTTPS: `FRAME_DOMAIN`, `FRAME_PUBLIC_URL=https://`, `FRAME_ACME_EMAIL`, `FRAME_NAME`, then `sudo sh install.sh --reconfigure` and `sudo systemctl start frame`. Tunnel: `FRAME_LISTEN=127.0.0.1:8080`, `FRAME_PUBLIC_URL`, `FRAME_TRUSTED_PROXIES=cloudflare,127.0.0.1/32`, `FRAME_NAME` and no `FRAME_DOMAIN`; then install `cloudflared` with the two commands Cloudflare shows me and set the public hostname's service to HTTP `localhost:8080`. Existing Caddy/nginx: `FRAME_LISTEN=127.0.0.1:8080`, `FRAME_PUBLIC_URL`, `FRAME_TRUSTED_PROXIES=127.0.0.1/32,::1/128`, and a proxy that passes `Host` and `X-Forwarded-For`, allows 64 MB bodies and doesn't buffer `/api/v1/events`. - Docker Compose: `git clone https://github.com/Crow-Tech-Pte-Ltd/frame.git`, `cd frame/deploy`, `cp frame.conf.example frame.conf`, `chmod 600 frame.conf`. With Caddy: leave `FRAME_DOMAIN` unset, `echo "FRAME_HOSTNAME=" > .env`, then `docker compose -f docker-compose.caddy.yml up -d` (pass `-f docker-compose.caddy.yml` to every later command). With a tunnel: set `FRAME_PUBLIC_URL` and `FRAME_NAME`, put `CLOUDFLARE_TUNNEL_TOKEN=…` in `.env`, and in `docker-compose.yml` remove the `ports:` block, uncomment the `cloudflared` service and the (b) `FRAME_TRUSTED_PROXIES` line; the tunnel's service is `http://frame:8080`. Then `docker compose up -d`. 4. **Check it:** `https:///healthz` says `ok` (from a phone on mobile data for a tunnel), and `https:///api/v1/server` shows JSON with the server name. Logs: `journalctl -u frame -n 100` or `docker compose logs frame`. 5. **Admin account.** The server logs a one-time setup link (valid once, for 24 hours). Let me run the command that shows it myself, so the link stays out of this chat: `sudo journalctl -u frame | grep "admin account"` or `docker compose logs frame | grep "admin account"` (a fresh link: `sudo frame-admin setup-link` or `docker compose exec frame frame-server admin setup-link`). Then stop: I open it, create the account, and turn on two-factor authentication under **Security** in the panel. Admin commands always go through `sudo frame-admin …` (never `frame-server` as root) or `docker compose exec frame frame-server admin …`. 6. **Admin panel exposure** (only if I asked): `FRAME_ADMIN_LISTEN=127.0.0.1:8081` and I reach it with `ssh -N -L 8081:127.0.0.1:8081 me@server` and `http://localhost:8081/admin/`; or `FRAME_ADMIN=local` (a proxy or tunnel on the same host must then be in `FRAME_TRUSTED_PROXIES`); or `FRAME_ADMIN=off`. Add `FRAME_ADMIN_APP=on` if I still want Server admin in the apps. Keep `FRAME_PUBLIC_URL` set. With Docker, publish `127.0.0.1:8081:8081` and use `FRAME_ADMIN_LISTEN=:8081`. Restart after every change. 7. **Optional settings:** `FRAME_REGISTRATION` (`invite` is already the default; setting it locks it in the panel), `FRAME_SMTP_URL` and `FRAME_SMTP_FROM` (quote secrets containing `$` or `\` in single quotes), R2 with `FRAME_STORAGE=s3` and the `FRAME_S3_*` settings (a private bucket and a token scoped to it; decide before people post). 8. **Connect the apps.** I install Frame, type the domain without `https://` at **Where's your Frame?**, and sign in with the admin account. Friends join with an invite from the panel (**Invites → New invite**) or from the app. 9. **Push** works out of the box through Crow Tech's content-blind relay at `https://frame.crow.sg` (end-to-end encrypted; it stores nothing). Only if I want it off: `FRAME_PUSH_RELAY=` (empty). Check the server can reach it: `curl -sI https://frame.crow.sg/healthz`. 10. **Backups**, nightly, copied off the machine. systemd: `sudo frame-admin backup /root/frame-$(date +%F).db` plus `sudo tar -C /var/lib/frame -czf /root/frame-files-$(date +%F).tgz secrets.json media`, in root's crontab (escape `%` as `\%` there). Docker: `docker compose exec frame frame-server backup /data/frame-backup.db`, `docker compose cp frame:/data/frame-backup.db ./frame-$(date +%F).db`, and a tar of `secrets.json` and `media` from the `frame_frame-data` volume. Then help me test a restore. ## Verify at the end - `systemctl status frame` says active (or `docker compose ps` shows it up) and `/healthz` says `ok` over HTTPS with a valid certificate. - The admin panel opens where I chose, and is `404` at `https:///admin` if I made it private. - I'm signed in in the app, two-factor authentication is on, and an invite link works. - A backup file and the files archive exist off the machine. ## Common pitfalls - Certificates fail: the DNS record is proxied (orange cloud), ports 80/443 aren't reachable, or `install.sh --reconfigure` wasn't run after setting `FRAME_DOMAIN` (log says "permission denied"). Let's Encrypt rate-limits retries: wait an hour. - Apple/Google sign-in buttons missing: `FRAME_PUBLIC_URL` not set to the exact address people type. - Everyone gets "too many requests": `FRAME_TRUSTED_PROXIES` doesn't match the proxy or tunnel. Unset it with built-in HTTPS. - Cloudflare Access in front of the hostname blocks the apps: don't add it. - Uploads fail behind a proxy: allow about 64 MB request bodies. Slow live updates: turn off buffering for `/api/v1/events`. - "readonly database" after admin commands: someone ran `frame-server` as root. Fix with `sudo chown -R --reference=/var/lib/private/frame /var/lib/private/frame && sudo systemctl restart frame`. ## Rules for you - Never pipe a downloaded script into a shell; download it, show me, run it. - Treat the setup link, tunnel token, SMTP and R2 credentials, `frame.conf` and `secrets.json` as secrets: don't print them into this chat or logs, and never commit them. Prefer that I paste secrets into files myself. - Stop and let me act when a browser, DNS dashboard, Cloudflare dashboard or the phone is involved. - Don't open other ports, put a login page in front of the server, delete `/var/lib/frame` or the Docker volume, or switch storage after people have posted. - `secrets.json` must be backed up: losing it makes R2 media unreadable forever. ## Full documentation - Overview: https://crow.sg/apps/frame/docs/ (Markdown: https://crow.sg/apps/frame/docs/index.md) - Quickstart: https://crow.sg/apps/frame/docs/quickstart/ - Docker Compose: https://crow.sg/apps/frame/docs/install-docker/ - Linux installer: https://crow.sg/apps/frame/docs/install-systemd/ - First run & admin account: https://crow.sg/apps/frame/docs/first-run/ - Connect the apps & invite: https://crow.sg/apps/frame/docs/connect/ - Keep the admin panel private: https://crow.sg/apps/frame/docs/private-admin/ - Push notifications: https://crow.sg/apps/frame/docs/push/ - Storage & sizing: https://crow.sg/apps/frame/docs/storage/ - Backups & restore: https://crow.sg/apps/frame/docs/backups/ - Configuration: https://crow.sg/apps/frame/docs/configuration/ - Troubleshooting: https://crow.sg/apps/frame/docs/troubleshooting/ - Every page in one Markdown file: https://crow.sg/apps/frame/docs/llms-full.txt ```` The agent can read any page of these docs as Markdown: add `.md` to a page's address (the overview is [index.md](https://crow.sg/apps/frame/docs/index.md)), or fetch [llms-full.txt](https://crow.sg/apps/frame/docs/llms-full.txt) for everything in one file. --- # Install with Docker Compose > Run the official Frame server image with Docker Compose: behind Caddy with automatic HTTPS, through a Cloudflare Tunnel with no open ports, with built-in HTTPS, or behind your own proxy. Source: https://crow.sg/apps/frame/docs/install-docker/ If you already use Docker, or don't want to touch systemd. The official image `ghcr.io/crow-tech-pte-ltd/frame-server:0` is built for amd64 and arm64, so it also runs on a Raspberry Pi with a 64-bit OS. The `:0` tag follows 0.x releases. ## Get the files Install Docker Engine with the Compose plugin, from your distribution's packages or [Docker's official instructions](https://docs.docker.com/engine/install/). Then get the repository and create the config: ```sh git clone https://github.com/Crow-Tech-Pte-Ltd/frame.git cd frame/deploy cp frame.conf.example frame.conf chmod 600 frame.conf ``` Every `docker compose` command below runs in `frame/deploy`. In every setup, set `FRAME_PUBLIC_URL=https://frame.example.com` (your address) and `FRAME_NAME` in `frame.conf`; the Caddy file sets `FRAME_PUBLIC_URL` for you. Then pick one way to make the server reachable. ## With Caddy (automatic HTTPS) Caddy sits in front of Frame and gets HTTPS certificates for your domain. Use this on a server with a public IP address. 1. **Point your domain at the server** At your DNS provider, add an `A` record (and optionally `AAAA`) for `frame` with the server's IP address. On Cloudflare DNS, set it to **DNS only**. Open ports 80 and 443 (TCP), plus 443/UDP for HTTP/3. 2. **Write the config** ```sh nano frame.conf # set FRAME_NAME; leave FRAME_DOMAIN unset echo "FRAME_HOSTNAME=frame.example.com" > .env ``` `docker-compose.caddy.yml` sets two things for you from `FRAME_HOSTNAME`: `FRAME_PUBLIC_URL=https://`, and `FRAME_TRUSTED_PROXIES` set to the Compose network Caddy lives on (`172.31.250.0/24`, pinned in the file), so Frame sees each visitor's IP instead of Caddy's. 3. **Start it** ```sh docker compose -f docker-compose.caddy.yml up -d # pulls the official image docker compose -f docker-compose.caddy.yml logs -f frame ``` Open `https://frame.example.com/healthz`. It should say `ok`. Caddy health-checks Frame through the same path. Then [create your admin account](https://crow.sg/apps/frame/docs/first-run/). > [!NOTE] Keep passing the Caddy file > Every `docker compose` command for this setup needs `-f docker-compose.caddy.yml`, including the admin, backup and update commands in these docs. In the Caddy setup the Frame container publishes no ports: only Caddy can reach it. ## With Cloudflare Tunnel Run Frame at home with no open ports and no router changes. This also works behind carrier-grade NAT. You need a domain whose DNS is on Cloudflare (the free plan is fine). 1. **Create the tunnel** In the Cloudflare dashboard, open **Zero Trust → Networks → Tunnels → Create a tunnel**, choose **Cloudflared** and name it `frame`. Copy the tunnel token: it is a secret. Add a public hostname `frame.example.com` whose service is `http://frame:8080`. 2. **Write the config** ```sh nano frame.conf # FRAME_PUBLIC_URL=https://frame.example.com and FRAME_NAME echo "CLOUDFLARE_TUNNEL_TOKEN=" > .env ``` 3. **Switch docker-compose.yml to the tunnel** In `docker-compose.yml`: - remove the `ports:` block of the `frame` service; - uncomment the `cloudflared` service; - uncomment the line marked (b), `FRAME_TRUSTED_PROXIES: "cloudflare,172.31.251.0/24"`. Frame then trusts the visitor IP only when it comes from the tunnel container. 4. **Start it** ```sh docker compose up -d # pulls the official image docker compose logs -f frame ``` On your phone, on mobile data, open `https://frame.example.com/healthz`. It should say `ok`. > [!WARNING] Good to know > Don't put Cloudflare Access or another login page in front of this hostname: the apps can't get through it. Cloudflare's free plan limits uploads to 100 MB per request, which is above Frame's 50 MB default. Cloudflare ends the HTTPS connection, so it can technically see the traffic; if that matters to you, use built-in HTTPS or Caddy instead. ## With built-in HTTPS Frame can get its own Let's Encrypt certificate. Set `FRAME_DOMAIN=frame.example.com` and `FRAME_PUBLIC_URL=https://frame.example.com` in `frame.conf`, and in `docker-compose.yml` replace the `127.0.0.1:8080:8080` port line with `80:80` and `443:443`. DNS and firewall are as in the Caddy setup above. Leave `FRAME_TRUSTED_PROXIES` unset: phones connect to Frame directly. Then `docker compose up -d`. ## Behind your own proxy on the host If nginx, Caddy or another proxy already runs on the host and forwards to `127.0.0.1:8080`: keep the `127.0.0.1:8080:8080` port line and uncomment the line marked (d), `FRAME_TRUSTED_PROXIES: "172.31.251.1/32"`. Docker delivers those connections from the network's gateway address. Your proxy needs the settings in [Behind Caddy, nginx or another proxy](https://crow.sg/apps/frame/docs/install-systemd/#reverse-proxy). The compose file pins its Docker network to `172.31.251.0/24` so these lines can name it. If that collides with a network you already use, change it in the file and in the lines above. ## Admin commands in Docker Find the one-time admin setup link, and run any admin command, through the container (add `-f docker-compose.caddy.yml` after `docker compose` for the Caddy setup): ```sh docker compose logs frame | grep "admin account" docker compose exec frame frame-server admin setup-link # a fresh link, if needed docker compose exec frame frame-server admin user list docker compose exec frame frame-server admin user create --email you@example.com --name "You" ``` Every command is in the [configuration reference](https://crow.sg/apps/frame/docs/configuration/#commands). ## Build the image yourself Both setups pull the official image. To build it from the repository instead (from `deploy/Dockerfile`), add the `docker-compose.build.yml` override: ```sh # Cloudflare Tunnel, built-in HTTPS or host proxy (docker-compose.yml) echo "COMPOSE_FILE=docker-compose.yml:docker-compose.build.yml" >> .env docker compose up -d --build # Caddy docker compose -f docker-compose.caddy.yml -f docker-compose.build.yml up -d --build ``` With the `COMPOSE_FILE` line in `deploy/.env`, later `docker compose` commands use your own build too. With the Caddy file, keep passing both `-f` options. ## What the container does The image is distroless: no shell and no package manager. The container runs as an unprivileged user (uid 65532) on a read-only filesystem, with no Linux capabilities and `no-new-privileges`. Everything Frame stores (database, local media and `secrets.json`) lives in the Docker volume `frame_frame-data`: back it up ([Backups & restore](https://crow.sg/apps/frame/docs/backups/)). There is no Docker health check, because the image has no shell or curl: monitor `https://frame.example.com/healthz` from outside, or use the Caddy setup, which checks it for you. --- # Install with the Linux installer (systemd) > Install the Frame server with its installer on Debian, Ubuntu, Raspberry Pi OS, Fedora or any Linux with systemd: built-in HTTPS on a VPS, a Pi behind a Cloudflare Tunnel, or behind Caddy or nginx. Source: https://crow.sg/apps/frame/docs/install-systemd/ For Debian, Ubuntu, Raspberry Pi OS, Fedora and other Linux systems with systemd, on amd64, arm64 or armv7. The installer sets up `frame-server` as a locked-down service; how the server is reached depends on your machine. ## What the installer does Download it, read it, then run it: ```sh curl -fsSLO https://github.com/Crow-Tech-Pte-Ltd/frame/releases/latest/download/install.sh less install.sh # read it (press q to quit) sudo sh install.sh ``` It does this, and nothing else: 1. picks the release binary for this CPU (amd64, arm64 or armv7); 2. downloads it plus `SHA256SUMS` from the GitHub release and **verifies the SHA-256 checksum** before installing anything; 3. installs `/usr/local/bin/frame-server`, the `/usr/local/bin/frame-admin` helper and `/etc/systemd/system/frame.service`; 4. creates `/etc/frame/frame.conf` (mode 0600) from the example on the first run; an existing config is never touched; 5. lets the service bind ports 80 and 443 only if `FRAME_DOMAIN` is set; 6. enables the service, and on upgrades restarts it if it was running. Re-running it is safe. Data in `/var/lib/frame` is never touched. Use `sudo frame-admin …` for admin commands: it runs them as the service's own user. ## On a VPS with built-in HTTPS The simplest always-on setup: Frame gets and renews its own certificate from Let's Encrypt. The [quickstart](https://crow.sg/apps/frame/docs/quickstart/) walks through it with every command. 1. **Point your domain at the server and open the firewall** Add an `A` record for `frame` with the server's IP (DNS only on Cloudflare). Then allow SSH and web traffic, and nothing else: ```sh sudo apt install -y ufw # Debian, Ubuntu, Raspberry Pi OS sudo ufw allow OpenSSH sudo ufw allow 80,443/tcp sudo ufw enable ``` 2. **Install, then configure** Run the installer as above, then `sudo nano /etc/frame/frame.conf`, remove the `#` and fill in: ```ini title="/etc/frame/frame.conf" FRAME_DOMAIN=frame.example.com FRAME_PUBLIC_URL=https://frame.example.com FRAME_ACME_EMAIL=you@example.com FRAME_NAME="Our Frame" ``` Leave `FRAME_TRUSTED_PROXIES` unset: phones connect to Frame directly, with no proxy in between. 3. **Start it** ```sh sudo sh install.sh --reconfigure # lets Frame use ports 80/443 because FRAME_DOMAIN is set sudo systemctl start frame systemctl status frame # should say "active (running)" journalctl -u frame -f # live logs; Ctrl+C to stop watching ``` Open `https://frame.example.com/healthz`. It should say `ok`. Then [create your admin account](https://crow.sg/apps/frame/docs/first-run/). ## At home with Cloudflare Tunnel Run Frame on a Raspberry Pi 3B+, 4, 5 or Zero 2 W, or any Linux PC, with **no open ports and no router configuration**. This also works behind carrier-grade NAT. You need a 64-bit OS (such as Raspberry Pi OS Lite 64-bit, Debian or Ubuntu) and a domain whose DNS is on Cloudflare: adding a domain to Cloudflare's free plan is free. Prefer an SSD or USB stick for the data over the SD card, which wears out, or keep media in [R2](https://crow.sg/apps/frame/docs/storage/). 1. **Install and configure Frame** Update the machine and run the installer as above; skip the firewall step, nothing needs to be opened. Then `sudo nano /etc/frame/frame.conf`: ```ini title="/etc/frame/frame.conf" FRAME_LISTEN=127.0.0.1:8080 FRAME_PUBLIC_URL=https://frame.example.com FRAME_TRUSTED_PROXIES=cloudflare,127.0.0.1/32 FRAME_NAME="Our Frame" ``` `FRAME_LISTEN=127.0.0.1:8080` means only programs on this machine, such as the tunnel, can reach the server. `FRAME_TRUSTED_PROXIES=cloudflare,127.0.0.1/32` makes Frame trust the visitor IP that Cloudflare passes along (the `CF-Connecting-IP` header), but only from `cloudflared` on this machine: rate limits then apply per visitor, and nobody else can fake the header. Don't set `FRAME_DOMAIN` here. 2. **Start it** ```sh sudo systemctl start frame curl http://127.0.0.1:8080/healthz # → ok ``` 3. **Create the tunnel** In the Cloudflare dashboard, go to **Zero Trust → Networks → Tunnels → Create a tunnel**, choose **Cloudflared** and name it `frame`. Choose your OS and architecture (Debian, arm64 for a Pi). Cloudflare shows two commands: one installs `cloudflared` from Cloudflare's package repository, and one is `sudo cloudflared service install `. Run both on your machine; the token is a secret. Under **Public Hostname**, set subdomain `frame`, your domain, service type **HTTP** and URL `localhost:8080`, and save. 4. **Test it** From your phone, on mobile data rather than Wi-Fi, open `https://frame.example.com/healthz`. It should show `ok`. Then [create your admin account](https://crow.sg/apps/frame/docs/first-run/). > [!WARNING] Good to know > Don't put Cloudflare Access (a login page) in front of this hostname: the apps can't get through it. Cloudflare's free plan limits uploads to 100 MB per request; Frame's default limit is 50 MB, so that's fine. Cloudflare terminates TLS, so it can technically see the traffic. If that bothers you, use built-in HTTPS on a VPS. ## Behind Caddy, nginx or another proxy If the machine already serves other websites, let its web server handle HTTPS. Install Frame as above, but don't set `FRAME_DOMAIN`: ```ini title="/etc/frame/frame.conf" FRAME_LISTEN=127.0.0.1:8080 FRAME_PUBLIC_URL=https://frame.example.com FRAME_TRUSTED_PROXIES=127.0.0.1/32,::1/128 ``` `FRAME_TRUSTED_PROXIES` tells Frame that the `X-Forwarded-For` header from the proxy on this machine carries the real visitor IP. For [Caddy](https://caddyserver.com/docs/install), add this to `/etc/caddy/Caddyfile` (the commented version is [`deploy/Caddyfile`](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/deploy/Caddyfile)), then run `sudo systemctl reload caddy` and `sudo systemctl start frame`: ```caddy title="/etc/caddy/Caddyfile" frame.example.com { request_body { max_size 64MB } reverse_proxy 127.0.0.1:8080 { flush_interval -1 health_uri /healthz } } ``` Using nginx or another proxy? You need four things: - Pass `Host` and `X-Forwarded-For`. - Allow request bodies of about 64 MB (nginx: `client_max_body_size 64m;`). - Don't buffer `/api/v1/events`, which is a Server-Sent Events stream (nginx: `proxy_buffering off;` and `proxy_read_timeout 1h;`). - Add the proxy's IP to `FRAME_TRUSTED_PROXIES`. ## Installer options | Option | What it does | |---|---| | `--version vX.Y.Z` | Install that release instead of the latest. | | `--binary PATH` | Install a locally built `frame-server` (no download); the unit, admin helper and example config come from the script's directory, a repository checkout's `deploy/`. See [Binary & source](https://crow.sg/apps/frame/docs/install-binary/#build). | | `--start` | Start the service even on the first install. | | `--reconfigure` | Only re-check `FRAME_DOMAIN`, reload and restart. Run it after setting or removing `FRAME_DOMAIN`. | | `--uninstall` | Remove the binary, unit and helper; keeps the configuration in `/etc/frame` and the data. | The environment variable `FRAME_REPO` (default `Crow-Tech-Pte-Ltd/frame`) points the installer at a fork. ## Useful commands ```sh systemctl status frame # running? journalctl -u frame -n 100 # last 100 log lines sudo systemctl restart frame # after editing frame.conf sudo frame-admin user list # admin commands: always through frame-admin ``` > [!WARNING] Always use `sudo frame-admin` > Running `frame-server` directly as root creates database files the service can't write. If that happened, see [Troubleshooting](https://crow.sg/apps/frame/docs/troubleshooting/#permission-denied). The service runs with a throwaway system user that can write only its data directory, a read-only view of the system and no capabilities beyond binding ports 80/443 when `FRAME_DOMAIN` needs them. Details in [Security](https://crow.sg/apps/frame/docs/security/#hardening). --- # Run the binary or build from source > Run a Frame server release binary yourself, build frame-server from source with Go, install your own build as the service, or try Frame locally in dev mode. Source: https://crow.sg/apps/frame/docs/install-binary/ `frame-server` is one static Go binary with SQLite built in. With no configuration at all it runs a working server on plain HTTP port 8080, with its data in `./frame-data`. The [Linux installer](https://crow.sg/apps/frame/docs/install-systemd/) and [Docker](https://crow.sg/apps/frame/docs/install-docker/) wrap the same binary; use this page when you want to run it your own way. ## Run a release binary Every [release](https://github.com/Crow-Tech-Pte-Ltd/frame/releases/latest) has `frame-server-linux-amd64`, `frame-server-linux-arm64` and `frame-server-linux-armv7`, plus a `SHA256SUMS` file. Download the one for your CPU and check it before you run it: ```sh curl -fsSLO https://github.com/Crow-Tech-Pte-Ltd/frame/releases/latest/download/frame-server-linux-amd64 curl -fsSLO https://github.com/Crow-Tech-Pte-Ltd/frame/releases/latest/download/SHA256SUMS sha256sum --check --ignore-missing SHA256SUMS # must say OK chmod +x frame-server-linux-amd64 ./frame-server-linux-amd64 version ``` Settings come from environment variables, optionally loaded from a `KEY=value` file passed with `--config`; real environment variables override the file. Start from the annotated [`frame.conf.example`](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/deploy/frame.conf.example) and the [configuration reference](https://crow.sg/apps/frame/docs/configuration/): ```sh ./frame-server-linux-amd64 --config frame.conf # same as: frame-server serve --config frame.conf ``` Set at least `FRAME_PUBLIC_URL`, and `FRAME_DATA_DIR` if the data shouldn't live in `./frame-data`. Run it as an unprivileged user that owns the data directory, and keep it running across reboots yourself, or let the installer do that for you with `--binary` (below). The admin commands are subcommands of the same binary, such as `frame-server admin setup-link`; run them as the user that owns the data. > [!NOTE] The checksum isn't a signature > `SHA256SUMS` comes from the same GitHub release, so it catches corrupted or altered downloads, not a compromised release. ## Build from source You need Go 1.26 (see `server/go.mod`; with the default `GOTOOLCHAIN=auto`, an older Go downloads the pinned toolchain). The web admin panel is prebuilt into the source tree, so `go build` needs neither Node nor Bun. ```sh git clone https://github.com/Crow-Tech-Pte-Ltd/frame.git cd frame/server CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "-s -w -X main.version=0.1.0" -o bin/frame-server-linux-arm64 ./cmd/frame-server ``` Change `GOARCH` (and the output name) for your machine. To run your build as the hardened systemd service, install it with the installer from the same checkout, which takes the unit, the admin helper and the example config from `deploy/`: ```sh sudo sh ../deploy/install.sh --binary bin/frame-server-linux-arm64 ``` With Docker, build the image from the checkout instead: see [Build the image yourself](https://crow.sg/apps/frame/docs/install-docker/#build-yourself). ## Try it locally From a source checkout, dev mode starts a throwaway server with demo data: ```sh cd frame/server go run ./cmd/frame-server dev ``` It listens on `0.0.0.0:8787`, stores its data in `server/.devdata/`, opens registration, turns push off and seeds three users who are all friends: `alice`, `bob` and `carol`, password `password123`. `alice` is an admin of the web panel at `http://localhost:8787/admin/`. Delete `.devdata` to start over. Don't use dev mode for a real server. Pointing an app at a test server: - **Android emulator:** `http://10.0.2.2:8787` (or `:8080` for a normal server). Inside the emulator, `10.0.2.2` is the computer running it. - **iOS simulator:** `http://localhost:8787` works. - **A real phone on the same Wi-Fi:** your computer's LAN address, such as `http://192.168.1.20:8080`. The firewall must allow the port, and `FRAME_LISTEN` must not be `127.0.0.1`. The apps accept plain `http://` only for localhost and private LAN addresses. Anything on the internet needs HTTPS. --- # First run: your admin account > Create the first admin of a new Frame server with the one-time setup link from the log, turn on two-factor authentication, and add more admins. Source: https://crow.sg/apps/frame/docs/first-run/ A new Frame server has no admin. On every start while that is true, it writes a one-time link to its log. The account you create with it is your normal Frame account and also the admin of the web panel at `/admin/`. ## Find the setup link ```text output Create your admin account: https://frame.example.com/admin/setup#t=… ``` | Setup | Find the link | |---|---| | Linux installer (systemd) | `sudo journalctl -u frame \| grep "admin account"` | | Docker Compose | `docker compose logs frame \| grep "admin account"` | | A fresh link | `sudo frame-admin setup-link` or `docker compose exec frame frame-server admin setup-link` | With the Caddy setup, add `-f docker-compose.caddy.yml` after `docker compose`. Each new link replaces the previous one. - The link works **once** and expires after **24 hours**. The token is in the `#` part of the URL, which browsers never send to servers or proxies, and the server only stores a hash of it. - The link uses `FRAME_ADMIN_LISTEN`'s address when that is set, else `FRAME_PUBLIC_URL`, else `http://localhost:`: replace the host with however you reach the server. ## Create the account Open the link and choose your name, a username, an e-mail address or phone number, and a password. Then: 1. Sign in to the Frame app with the same details (see [Connect the apps & invite](https://crow.sg/apps/frame/docs/connect/)). 2. In the panel, open **Security** and turn on two-factor authentication with an authenticator app such as Aegis, 1Password or Google Authenticator. Keep the eight recovery codes somewhere safe. The panel reminds you until you do. 3. Open **Invites → New invite** to get a link and QR code for each friend, or invite friends from the app. Prefer the command line? This creates an admin account and prints a one-time password instead: ```sh sudo frame-admin user create --email you@example.com --name "Your Name" --admin ``` With Docker: `docker compose exec frame frame-server admin user create --email you@example.com --name "Your Name" --admin`. ## More admins Open a user in the panel and choose **Make admin**, or run `sudo frame-admin user promote `. `demote` removes admin rights again; the last admin can't be removed. ## Locked out? - **No admin yet and the link is lost:** `sudo frame-admin setup-link` prints a fresh one. - **Forgot your password:** `sudo frame-admin user reset-password ` prints a one-time password. - **Lost your authenticator and recovery codes:** `sudo frame-admin user reset-2fa `. - **Your account isn't an admin:** `sudo frame-admin user promote `. With Docker, use `docker compose exec frame frame-server admin …` for each of these. --- # Connect the apps and invite friends > Point the Frame apps at your server, decide who can join (invite-only, open or closed), and create, share and revoke invite links. Source: https://crow.sg/apps/frame/docs/connect/ Everyone in your group uses the same Frame apps and types your server's address when they sign up. Friends on the same server can see each other's photos; friends on different servers can't, so pick one server per group. - **iPhone:** [Download on the App Store](https://apps.apple.com/app/id6832514449) - **Android:** Coming soon to Google Play - **Server:** [Free and open source · GitHub](https://github.com/Crow-Tech-Pte-Ltd/frame) ## Connect the app 1. Open Frame. When it asks **Where's your Frame?**, type your address, such as `frame.example.com`, without `https://` and without a trailing slash. The app adds the rest. 2. Sign in with the account you made with the [setup link](https://crow.sg/apps/frame/docs/first-run/), or create one with an invite. 3. Invite friends from the app, or from **Invites** in the admin panel. If the app can't connect, open `https://frame.example.com/api/v1/server` in your phone's browser: you should see JSON with your server's name. If you don't, see [Troubleshooting](https://crow.sg/apps/frame/docs/troubleshooting/#cant-connect). The apps only accept plain `http://` for local network addresses, so anything on the internet needs HTTPS. ## Who can join New servers are **invite-only**. Change this in the admin panel under **Settings**, or pin it with `FRAME_REGISTRATION` in `frame.conf` (a setting in the file is locked in the panel). | Mode | Who can create an account | Good for | |---|---|---| | `invite` (default) | Only people with an invite: a link from the admin panel, or, when **Members can invite** is on (`FRAME_USER_INVITES`, on by default), a link a member shares from the app | Most servers. You and your friends invite friends. | | `open` | Anyone who knows your server address | Quick tests. Not recommended for a public server. | | `closed` | Nobody. Admins create accounts in the panel (**Users → New user**) or with `frame-admin user create` | Family servers with a fixed list of people. | Signing in to an existing account never needs an invite. ## Server invites In the admin panel, **Invites → New invite** makes a link that lets someone create an account, by default once and within 7 days. Add a note ("for Mum") to remember who it's for, copy the link or show the QR code, and see who joined with it. A server invite doesn't make anyone a friend; they add friends in the app. From the command line: ```sh sudo frame-admin invite server --uses 1 --days 7 --note "for Mum" ``` `--days 0` makes an invite that never expires. ## Members' invite links In the app, open **Friends → Invite** and share the link, which looks like `https://frame.crow.sg/i#s=…&c=…`. When your friend opens it: - If they have Frame, the link opens it with your server filled in, and after sign-up you're **already friends**. - If not, the page shows the store buttons and a "copy server address" button. - Your server address and the code travel in the part after `#`, which browsers never send to `frame.crow.sg`. These links are valid for 30 days and can be used several times. Anyone holding one can become your friend, so share it privately. Turn off **Members can invite** if only you should decide who joins; members' links then still work for adding friends who are already on the server. An admin can make a member's link on the server with `sudo frame-admin invite create `. ## Revoking invites - Shared a link with the wrong person? In the app, use **Settings → Reset invite link**. This revokes all your open invites at once and gives you a new link. Revoked or expired links stop working immediately. - In the admin panel, **Invites** lists every server invite and every member's open link, each with a **Revoke** button. - Someone you previously **unfriended or blocked** can't use an old or forwarded invite to get back in: for them the link only creates a normal friend request that you must accept. A blocked person gets nothing. - Friendships already made through an invite aren't undone by revoking it. Unfriend or block the person instead. Friends on the same server can also find each other by username search, or by contacts if they allow it. There's no friend limit unless you set `FRAME_MAX_FRIENDS`. --- # The admin panel > Manage a Frame server from the web panel at /admin or from Server admin in the apps: users, reports, invites, settings, two-factor authentication, the audit log and backups. Source: https://crow.sg/apps/frame/docs/admin-panel/ Every Frame server has a small web dashboard at `https://frame.example.com/admin/` for the person who runs it: invite people, manage accounts, review reports, change a few settings, see how much disk the server uses and download a backup. Admins can do most of the same from **Server admin** in the Frame apps. > [!NOTE] The panel doesn't show anyone's photos or messages > It shows accounts, counts and sizes, never moments, captions, chats or reactions. The one exception is an item a member **reported** to you: the report shows that single moment, message or profile, because the member chose to share it with you. (The operator can still read the database directly; see [Security](https://crow.sg/apps/frame/docs/security/). The panel simply doesn't make browsing people's content part of running a server.) ## What you can do | Page | What it's for | |---|---| | **Overview** | Server name, version and uptime, registration mode, open reports, user counts (total, active in the last 7 days, suspended, admins), moments and messages, media and database size, whether the push relay is reachable, the latest sign-ups and admin actions. | | **Users** | Search, filter and sort accounts. Open a user to see their signed-in devices (and sign them out) and their invite links, and to **reset their password** (a one-time password is shown once), **suspend** or unsuspend them, **make or remove admin**, or **delete** the account (you type the username to confirm). You can also **create an account**, which you need when registration is `closed`. | | **Reports** | What members reported from the app (a moment, a message or a profile), open reports first. Open a report to see the reported item and **delete it**, **suspend** its author, or **dismiss** the report. | | **Invites** | Create a **server invite** (one use and 7 days by default, with an optional note), copy it or show it as a QR code, see who joined with it, revoke it. Also lists your members' friend-invite links, so you can revoke any of them. | | **Settings** | Registration mode, whether members' invite links can bring in new people, server name, maximum video length, maximum upload size, maximum friends. Anything set in `frame.conf` is **locked** ("set in frame.conf"). | | **Security** | Change your password, turn on two-factor authentication, get recovery codes, and see or sign out your admin sessions. | | **Audit log** | Every admin action: who, what, on whom, when and from which IP. | | **Maintenance** | Download a consistent database backup (asks for your password again) and run the orphaned-media cleanup. | The panel works on a phone, follows your system's light or dark setting, and loads nothing from other websites. ## Reports Frame never scans anything automatically. Members can report a moment, a message or a person from the app, which asks **you**, the server's admin, to look at one thing. Reports go only to your server, never to Crow Tech or anyone else. - A report holds who reported, whose content it is, the type, a reason (spam, harassment, nudity, violence, self-harm, other), an optional note of up to 500 characters and the date. Only ids are stored, never a copy of the content. - Opening a report shows **exactly the reported item** and nothing around it. Opening a reported moment or message is written to the audit log. - **Delete** removes the moment or message as if its author had. **Suspend** signs the author out everywhere and stops them signing in, until you undo it in Users. **Dismiss** does nothing to anyone. Each action resolves every open report on the same item. - In the app, reporting a moment also hides it from the reporter's feed, and the reporter can block the author in the same step. ## Server admin in the apps Admins get a **Server admin** menu in the Frame apps with the overview, users, reports, invites, settings, the audit log and the media cleanup. It goes through the normal app API on the server's main address, so it works wherever the app works, and every check the panel makes applies. `FRAME_ADMIN_APP` decides whether the server offers it: | Value | Server admin in the apps | |---|---| | `auto` (default) | On only when the panel itself is on the main address (`FRAME_ADMIN=on` and no `FRAME_ADMIN_LISTEN`). If you made the panel private, the apps don't open a new way in. | | `on` | On, also when the panel is `local` or on its own address. The web panel stays where it is. `FRAME_ADMIN=off` still turns all admin access off. | | `off` | Off. | The server logs the effective setting when it starts. - **Unlocking** the menu asks for your password and, with two-factor authentication on, a code, with the same attempt limits as the panel's sign-in. It is written to the audit log. Accounts that only sign in with Apple or Google set a password first. - **Bound to this app session:** the admin session ends when you leave the menu, after 30 minutes idle or 12 hours at most, and when you sign out, the device is revoked, your password changes, or you lose admin rights. It is listed under **Security** in the panel as "Frame app · *device*". - **Not in the apps:** the panel's own sign-in and setup, **Security** (password, two-factor authentication, recovery codes) and backups. Those stay in the web panel. ## How the panel is protected - Only admin accounts can sign in. Every request re-checks that the account is still an admin and not suspended. - Sessions use an `HttpOnly`, `SameSite=Strict` cookie stored only as a hash, and end after **30 minutes idle** or **12 hours**. - Sign-in is rate limited like the app's. Two-factor codes are limited to 10 attempts per 15 minutes per account. - Two-factor authentication uses TOTP with eight single-use recovery codes. Lost both? `sudo frame-admin user reset-2fa `. - Changes need a same-origin request with a custom header, and a strict Content Security Policy blocks inline scripts, third-party resources and framing. - Downloading a backup and changing two-factor settings ask for your password again. You can't suspend, demote or delete yourself, and the last admin can't be demoted. To take the panel off the internet entirely, see [Keep the admin panel private](https://crow.sg/apps/frame/docs/private-admin/). The full design, including the admin API, is in [ADMIN.md](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/docs/ADMIN.md). ## From the command line With the Linux installer, always use `sudo frame-admin …`: it runs the command as the service's own user. With Docker, use `docker compose exec frame frame-server admin …` instead. Commands work directly on the data directory, are safe while the server runs, and are recorded in the audit log as `CLI`. ```sh sudo frame-admin setup-link # one-time link to create the first admin sudo frame-admin user list sudo frame-admin user create --email alice@example.com --name Alice # prints a one-time password sudo frame-admin user create --phone +34600111222 --name Bob sudo frame-admin user create --email you@example.com --name You --admin # an admin for the web panel sudo frame-admin user promote alice # make an existing user an admin sudo frame-admin user demote alice # (never the last admin) sudo frame-admin user reset-2fa alice # admin lost their authenticator sudo frame-admin user reset-password alice # prints a one-time password sudo frame-admin user unlink alice apple # remove a linked Apple or Google sign-in sudo frame-admin user delete alice # irreversible sudo frame-admin invite server --uses 1 --days 7 --note "for Mum" # server invite link sudo frame-admin invite create alice # alice's friend-invite link sudo frame-admin backup /root/frame-backup.db sudo frame-admin version ``` Password resets by e-mail only work if you configure `FRAME_SMTP_URL` ([Configuration](https://crow.sg/apps/frame/docs/configuration/#email)). Otherwise the app tells people to ask you, and you use **Reset password** in the panel or `reset-password`. --- # Keep the admin panel private > Take the Frame admin panel off the internet: serve it on a local address and reach it over an SSH tunnel, allow only local clients, or turn it off, and keep Server admin in the apps if you want it. Source: https://crow.sg/apps/frame/docs/private-admin/ By default the admin panel is served at `/admin` on the same address as the app. That's convenient and the panel is well protected, but if you don't need it on the internet, take it off. Pick one: | Setting | Effect | |---|---| | `FRAME_ADMIN=on` (default) | `/admin` is served next to the app API on the same address. | | `FRAME_ADMIN_LISTEN=127.0.0.1:8081` | The panel is served **only** on this separate address; the main address answers `/admin` with `404`. Reach it over SSH. | | `FRAME_ADMIN=local` | `/admin` answers only clients on this machine or in `FRAME_TRUSTED_PROXIES` networks; everyone else gets `404`. | | `FRAME_ADMIN=off` | No web panel at all. The command line still works. | `FRAME_ADMIN_LISTEN` and `FRAME_ADMIN=local` can be combined for belt and braces. Restart after changing `frame.conf` (`sudo systemctl restart frame`, or `docker compose up -d`). > [!NOTE] Keep `FRAME_PUBLIC_URL` set > Invite links made in a private panel use `FRAME_PUBLIC_URL`. Without it they would point at `localhost`. ## A local address and an SSH tunnel Serve the panel only on the server's loopback address: ```ini title="frame.conf" FRAME_ADMIN_LISTEN=127.0.0.1:8081 ``` From your laptop, open a tunnel to it: ```sh ssh -N -L 8081:127.0.0.1:8081 you@your-server ``` Then open `http://localhost:8081/admin/`. Nothing about the panel is exposed to the internet, and because browsers treat `localhost` as a secure context, this works without HTTPS. The first-run setup link also uses this address while `FRAME_ADMIN_LISTEN` is set. **With Docker**, publish the admin port on the host's loopback only: add `"127.0.0.1:8081:8081"` to the `ports:` of the `frame` service, and set `FRAME_ADMIN_LISTEN=:8081` in `frame.conf`. The SSH tunnel is the same. ## Only from this machine `FRAME_ADMIN=local` keeps `/admin` on the main address but answers only clients on loopback or in the `FRAME_TRUSTED_PROXIES` networks. Behind a reverse proxy, the real client IP counts, not the proxy's. > [!WARNING] List a proxy or tunnel on the same host > A reverse proxy or tunnel on the same machine must be listed in `FRAME_TRUSTED_PROXIES` (for cloudflared, the `cloudflare` keyword). Requests it forwards carry visitor-IP headers, and from an unlisted loopback peer those are refused rather than treated as local. ## Turn it off `FRAME_ADMIN=off` removes the web panel and the admin API. Manage the server with [`sudo frame-admin`](https://crow.sg/apps/frame/docs/admin-panel/#commands) (or `docker compose exec frame frame-server admin …`). The server logs a warning if `FRAME_ADMIN_APP=on` is also set, because off wins. ## Keep Server admin in the apps By default (`FRAME_ADMIN_APP=auto`), making the panel private also removes the **Server admin** menu from the apps, so the apps don't open a new way in. To keep it in the apps while the web panel stays private: ```ini title="frame.conf" FRAME_ADMIN_LISTEN=127.0.0.1:8081 FRAME_ADMIN_APP=on ``` The apps reach the admin API through the normal app API on the main address, with a step-up unlock (your password and two-factor code) bound to the app session. Two-factor setup, password changes and backups stay in the web panel. More in [Server admin in the apps](https://crow.sg/apps/frame/docs/admin-panel/#in-the-apps). --- # Push notifications and the relay > How Frame delivers iPhone and Android notifications for self-hosted servers through Crow Tech's content-blind push relay, what the relay can and can't see, and how to turn push off. Source: https://crow.sg/apps/frame/docs/push/ Apple and Google only deliver notifications that are signed with the **app publisher's** keys, and self-hosted servers can't have those. So your server hands notifications to a small relay run by Crow Tech, the Frame publisher, at `https://frame.crow.sg`, which passes them on to Apple (APNs) for iPhones and to Google (FCM) for Android phones. Nothing to set up: it's on by default. ## What the relay can see The relay is built to learn as little as possible: - **The content is end-to-end encrypted.** Each phone makes its own key and shares it only with your server. The relay, Apple and Google only see ciphertext plus a generic "Frame: New activity". The phone decrypts the notification and shows the real text and thumbnail. - **The relay stores nothing.** It has no database and doesn't log device tokens, payloads or IP addresses. - **Your server never sees the phones' real push tokens**, only sealed relay tokens that only the relay can open. Your server can't use them to push to anyone else's device. What the relay *can* know is that some server, identified by its IP address, asked it to deliver a notification of a certain size at a certain time. It could also drop or delay notifications. It can't read content or learn who your users are. ## Push permission expires Each phone's relay token stops working after **60 days**. The apps fetch a fresh one every time they open and send it to your server, so phones that are in use never notice. If someone uninstalls the app or stops using a server, that server loses the ability to notify them after at most 60 days. Signing out removes it immediately. ## Widgets New photos also refresh your friends' home-screen widgets. On iPhone (iOS 26 and later) the server sends a separate widget push for every new moment, so widgets update even when notifications are muted. Without push, widgets refresh about every 15 minutes. ## Turn push off Set an empty value and restart: ```ini title="frame.conf" FRAME_PUSH_RELAY= ``` The apps still work. Widgets refresh about every 15 minutes, and the app updates live while it's open. Notifications for new messages only arrive when the app is opened. Running your own relay only makes sense if you also build and sign the apps yourself; the protocol is in [PUSH.md](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/docs/PUSH.md). ## When notifications don't arrive - Check that `FRAME_PUSH_RELAY` isn't empty. The admin panel's **Overview** shows whether the relay is reachable. - Check that the server can reach the internet: ```sh curl -sI https://frame.crow.sg/healthz # can the server reach the relay? journalctl -u frame | grep "push failed" # or: docker compose logs frame | grep "push failed" ``` - On the phone, allow notifications for Frame. On Android, also turn off battery optimisation for Frame. On iOS, check Focus modes. --- # Storage and sizing > Store Frame's photos and videos on local disk or in Cloudflare R2 (or any S3-compatible bucket, encrypted before upload), and estimate the disk, bandwidth and money a group needs. Source: https://crow.sg/apps/frame/docs/storage/ Decide on storage **before** your friends start posting: switching an existing server between local disk and R2 is not automatic. ## Local disk (default) Photos and videos are stored as files in the data directory: `/var/lib/frame/media` with the Linux installer, or the `frame-data` volume with Docker. This is the simplest option, and backups are copies of the folder. Files are stored as uploaded; use full-disk encryption on the host if that matters to you. ## Cloudflare R2 or another S3 bucket Useful when your disk is small (a Pi's SD card, a tiny VPS) or you want media off the machine. R2 has **10 GB of free storage and free egress**, so viewing photos costs nothing. Frame **encrypts every file before uploading it** (AES-256-GCM), so the bucket only ever stores ciphertext. 1. **Open R2** In the Cloudflare dashboard, open **R2 Object Storage**. The first time, Cloudflare asks for a payment method; you aren't charged within the free tier. 2. **Create a private bucket** Create a bucket, for example `frame-media`, with automatic location. Leave it **private**: don't enable public access or a public `r2.dev` URL. Frame serves the media itself. 3. **Create a token for that bucket only** Under **R2 → Manage API tokens → Create API token**, choose the permission **Object Read & Write** and **Apply to specific buckets only**, then pick `frame-media`. Copy the **Access Key ID**, the **Secret Access Key** (shown only once) and the **S3 endpoint** (`https://.r2.cloudflarestorage.com`). 4. **Configure Frame** ```ini title="frame.conf" FRAME_STORAGE=s3 FRAME_S3_ENDPOINT=https://.r2.cloudflarestorage.com FRAME_S3_BUCKET=frame-media FRAME_S3_REGION=auto FRAME_S3_ACCESS_KEY_ID= FRAME_S3_SECRET_ACCESS_KEY='' # FRAME_S3_ENCRYPT=true (the default; keep it) ``` Restart (`sudo systemctl restart frame`, or `docker compose up -d`), post a photo, and check that objects appear under `frame/` in the bucket. 5. **Back up the encryption key** Frame created `secrets.json` in its data directory. It holds the key that encrypts your bucket's objects. Keep a copy somewhere safe, such as a password manager or an offline USB stick ([Backups & restore](https://crow.sg/apps/frame/docs/backups/)). > [!WARNING] Lose `secrets.json`, lose the photos > Without `secrets.json`, every photo in the bucket is unreadable forever. Keep `frame.conf` private too: it now holds the bucket credentials. ## How much disk, bandwidth and money? Assumptions: photos are about **400 KB** and their thumbnails about **60 KB**; a 10-second video is about **3 MB**. Each photo or video is stored **once**, however many friends receive it. The server never re-encodes images or video. | Scenario | Uploads per day | Storage per day | **Storage per year** | |---|---|---|---| | 10 friends × 5 photos a day | 50 photos | 50 × 460 KB = 23 MB | **≈ 8.4 GB** | | 10 friends × (4 photos + 1 ten-second video) a day | 40 photos + 10 videos | 18.4 MB + 30.6 MB = 49 MB | **≈ 18 GB** | | 4 people × 3 photos a day (family) | 12 photos | 5.5 MB | **≈ 2 GB** | The database (messages, reactions, accounts) stays tiny in comparison: a few hundred MB after years. **Bandwidth.** In the photos-only case, if every moment reaches 9 friends who each load the thumbnail and the full photo, that's about 200 MB a day, or roughly **6 GB a month** of downloads. With videos it's about 13 GB a month. Cheap VPSs include terabytes, and R2 egress is free. | Setup | Monthly | |---|---| | A VPS with local storage | About €4–6. A 40 GB disk lasts the photos-only group about 4 years. | | A Raspberry Pi at home | Electricity: a Pi 4 draws about 3–5 W, roughly €1 a month. | | + Cloudflare R2 | **€0** up to 10 GB. Beyond that, about $0.015 per GB-month (≈ $0.15 a month for 20 GB). | | + a domain | About €10 a year. | **Hardware:** 1 CPU core and 512 MB–1 GB of RAM are enough. Frame itself uses roughly 30–60 MB, and even a Pi stays idle most of the time. --- # Backups and restore > Back up a Frame server's database, secrets.json and media every night, copy them off the server, and restore them on a new machine. Source: https://crow.sg/apps/frame/docs/backups/ Back up three things, and copy them off the server: | What | Where (Linux installer) | Why | |---|---|---| | The database, made with `backup` | written wherever you ask | Accounts, friendships, moments and messages. | | `secrets.json` | `/var/lib/frame/secrets.json` | Signing keys and the key that encrypts R2/S3 media. Lose it and that media can't be read. | | `media/` (local storage only) | `/var/lib/frame/media/` | The photos and videos. | The database backup is consistent and safe to take while Frame runs. With [R2](https://crow.sg/apps/frame/docs/storage/#r2), the media is already in the bucket, so the database and `secrets.json` are what matter; for extra safety, mirror the bucket elsewhere with `rclone sync`. ## Linux installer (systemd) ```sh sudo frame-admin backup /root/frame-$(date +%F).db # consistent, safe while running sudo tar -C /var/lib/frame -czf /root/frame-files-$(date +%F).tgz secrets.json media ``` Copy both files off the server, for example with `scp` to your computer, or with `restic` or `rclone` to another provider. Run it nightly with cron (`sudo crontab -e`): ```text title="crontab" 15 3 * * * /usr/local/bin/frame-admin backup /root/frame-$(date +\%F).db && tar -C /var/lib/frame -czf /root/frame-files-$(date +\%F).tgz secrets.json media ``` Old backups pile up, so delete them now and then, or let your backup tool rotate them. ## Docker ```sh docker compose exec frame frame-server backup /data/frame-backup.db docker compose cp frame:/data/frame-backup.db ./frame-$(date +%F).db docker run --rm -v frame_frame-data:/data:ro -v "$PWD":/backup alpine \ tar -C /data -czf /backup/frame-files-$(date +%F).tgz secrets.json media ``` With the Caddy setup, add `-f docker-compose.caddy.yml` after `docker compose`. ## From the admin panel **Maintenance** in the admin panel downloads a consistent copy of the database. It asks for your password again, and each download link works once, for two minutes. It doesn't include `secrets.json` or the media, so it doesn't replace the server-side backup above. ## Restore On the Linux installer (on a new machine, run `install.sh` first and copy over `/etc/frame/frame.conf`): ```sh sudo systemctl start frame && sudo systemctl stop frame # makes sure the data dir exists sudo tar -C /var/lib/frame -xzf frame-files-2026-11-07.tgz sudo cp frame-2026-11-07.db /var/lib/frame/frame.db sudo rm -f /var/lib/frame/frame.db-wal /var/lib/frame/frame.db-shm # hand everything back to the service's user (the owner of the data dir itself): sudo chown -R --reference=/var/lib/private/frame /var/lib/private/frame sudo systemctl start frame ``` Try a restore once, on a spare machine or a throwaway VPS, before you need it. > [!NOTE] Backups keep deleted content > Earlier backups still contain moments and messages people have since deleted. Rotate old backups, and keep them as private as the server. --- # Upgrading > Update a Frame server installed with the Linux installer or Docker Compose, pin a version, and get told about new releases. Source: https://crow.sg/apps/frame/docs/upgrading/ [Back up first](https://crow.sg/apps/frame/docs/backups/). Database changes run automatically when the new version starts, and your configuration and data are left alone. ## Linux installer (systemd) Download and read the installer again, then run it. It checks the checksum, replaces the program and restarts the service if it was running. ```sh curl -fsSLO https://github.com/Crow-Tech-Pte-Ltd/frame/releases/latest/download/install.sh less install.sh sudo sh install.sh # or: sudo sh install.sh --version v1.2.3 ``` ## Docker Compose ```sh cd frame && git pull # updates the compose files cd deploy && docker compose pull && docker compose up -d ``` With the Caddy setup, use `docker compose -f docker-compose.caddy.yml pull` and `… up -d`. If you [build the image yourself](https://crow.sg/apps/frame/docs/install-docker/#build-yourself), run `docker compose up -d --build` after `git pull` instead. The `:0` image tag follows 0.x releases. To upgrade only by hand, pin an exact version in the compose file, such as `ghcr.io/crow-tech-pte-ltd/frame-server:0.1.0`. ## Hear about new releases On the [GitHub repository](https://github.com/Crow-Tech-Pte-Ltd/frame), use **Watch → Custom → Releases**. Each release lists its changes; upgrade when one is announced, especially for security fixes. `sudo frame-admin version` (or `docker compose exec frame frame-server version`) shows what you're running, and so does the admin panel's **Overview**. --- # Configuration reference > Every Frame server setting (FRAME_PUBLIC_URL, FRAME_REGISTRATION, FRAME_ADMIN, FRAME_PUSH_RELAY, storage, limits, e-mail and more) and every admin command. Source: https://crow.sg/apps/frame/docs/configuration/ Settings come from environment variables, optionally loaded from a `KEY=value` file. With **no configuration at all**, `frame-server` runs a working server on plain HTTP port `8080` with its data in `./frame-data`. ## The config file | Install | File | Restart after a change | |---|---|---| | Linux installer | `/etc/frame/frame.conf` (root-only, mode 0600) | `sudo systemctl restart frame` | | Docker Compose | `deploy/frame.conf`, next to `docker-compose.yml` | `docker compose up -d` | | Binary | any file passed with `--config` | restart the process | - One `KEY=value` per line; lines starting with `#` are comments. Real environment variables override the file. - Wrap values containing spaces, quotes or `#` in double quotes (`FRAME_NAME="Daniel's Frame"`). Wrap secrets containing `$` or `\` in single quotes. - The server name, who can join, members' invites and the limits can also be changed in the admin panel, unless they are set in the file: anything set in the file shows as locked ("set in frame.conf") in the panel. Leave them out to manage them from the panel. - Keep the file private and never commit it: it can contain passwords and keys. The annotated example is [`deploy/frame.conf.example`](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/deploy/frame.conf.example). In Docker, the compose files fix `FRAME_DATA_DIR` and `FRAME_LISTEN` inside the container, and the Caddy file also clears `FRAME_DOMAIN` and sets `FRAME_PUBLIC_URL` and `FRAME_TRUSTED_PROXIES`, overriding `frame.conf`. ## Settings ### Basics and sign-in | Setting | Default | What it does | |---|---|---| | `FRAME_PUBLIC_URL` | `https://$FRAME_DOMAIN` | The address(es) people type into the app, comma-separated if the server is reachable under several names (the first is used in invite links). **Required for Sign in with Apple and Google**: without it, those methods are turned off. | | `FRAME_NAME` | `Frame` | Server name shown in the app. Panel setting. | | `FRAME_REGISTRATION` | `invite` | `invite` (new accounts need an invite), `open` (anyone who knows the address) or `closed` (only admins create accounts). Panel setting. | | `FRAME_USER_INVITES` | `true` | On invite-only servers, whether members' invite links from the app can also bring in new people. Panel setting. | | `FRAME_AUTH_METHODS` | `email,phone,apple,google` | Sign-in methods offered. Phone numbers are just identifiers: Frame sends no SMS. | | `FRAME_APPLE_AUDIENCES` | `com.crowtech.frame,com.crowtech.frame.signin` | Accepted audiences for Sign in with Apple. Change only if you build and sign the apps yourself. | | `FRAME_GOOGLE_CLIENT_IDS` | the official Frame apps' client ids | Accepted audiences for Google sign-in. Change only if you build the apps yourself. | ### Network and HTTPS | Setting | Default | What it does | |---|---|---| | `FRAME_DOMAIN` | — | Serve HTTPS on `:443` with automatic Let's Encrypt certificates and redirect `:80` to HTTPS. With the Linux installer, run `sudo sh install.sh --reconfigure` after setting it. | | `FRAME_ACME_EMAIL` | — | Optional contact e-mail for Let's Encrypt. | | `FRAME_LISTEN` | `:8080` | Plain-HTTP listen address, for use behind a reverse proxy or tunnel. `127.0.0.1:8080` keeps it reachable from this machine only. Ignored when `FRAME_DOMAIN` is set. | | `FRAME_TRUSTED_PROXIES` | — | Comma-separated IPs or CIDRs of reverse proxies whose `X-Forwarded-For` is trusted, so rate limits see real visitors. The word `cloudflare` also trusts `CF-Connecting-IP`, but only from loopback and the listed CIDRs. Leave unset when phones connect directly. | ### Admin panel | Setting | Default | What it does | |---|---|---| | `FRAME_ADMIN` | `on` | Web admin panel at `/admin`: `on`, `local` (only from loopback and `FRAME_TRUSTED_PROXIES` networks) or `off`. | | `FRAME_ADMIN_LISTEN` | — | A separate listen address for the panel, such as `127.0.0.1:8081`; `/admin` is then not served on the main address. | | `FRAME_ADMIN_APP` | `auto` | Server admin in the apps: `auto` (only when the panel is on the main address), `on` (also with a private panel; `FRAME_ADMIN=off` still wins) or `off`. | See [Keep the admin panel private](https://crow.sg/apps/frame/docs/private-admin/). ### Storage | Setting | Default | What it does | |---|---|---| | `FRAME_DATA_DIR` | `./frame-data` | The database (`frame.db`), local media (`media/`) and generated secrets (`secrets.json`). The installer and Docker set it (`/var/lib/frame`, `/data`). Back it up. | | `FRAME_STORAGE` | `local` | `local` or `s3`. | | `FRAME_S3_ENDPOINT` | — | For example `https://.r2.cloudflarestorage.com`. | | `FRAME_S3_BUCKET` | — | Bucket name (a private bucket). | | `FRAME_S3_REGION` | `auto` | `auto` for R2. | | `FRAME_S3_ACCESS_KEY_ID`, `FRAME_S3_SECRET_ACCESS_KEY` | — | Credentials of a token scoped to the bucket. | | `FRAME_S3_PREFIX` | `frame/` | Object key prefix. | | `FRAME_S3_ENCRYPT` | `true` | Encrypt objects (AES-256-GCM) before upload. The key is in `secrets.json`: losing it loses the media. | See [Storage & sizing](https://crow.sg/apps/frame/docs/storage/). ### Push and invites | Setting | Default | What it does | |---|---|---| | `FRAME_PUSH_RELAY` | `https://frame.crow.sg` | Push relay base URL. An empty value turns push off. | | `FRAME_INVITE_BASE` | `https://frame.crow.sg/i` | Base of shareable invite links. The server address travels in the link's `#` fragment, which browsers never send to that page. | ### Limits | Setting | Default | What it does | |---|---|---| | `FRAME_MAX_UPLOAD_MB` | `50` | Maximum upload size per photo or video. Panel setting. | | `FRAME_MAX_VIDEO_SECONDS` | `15` | Maximum video length the apps record and accept. Panel setting. | | `FRAME_MAX_FRIENDS` | `0` | Maximum friends per user; `0` is unlimited. Panel setting. | ### E-mail and logging | Setting | Default | What it does | |---|---|---| | `FRAME_SMTP_URL` | — | For example `smtps://user:password@smtp.example.com:465`. Enables "forgot password" e-mails; without it, admins reset passwords in the panel. | | `FRAME_SMTP_FROM` | — | From address for reset e-mails. | | `FRAME_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. Logs are JSON on stderr and never contain passwords, tokens or message text. | For e-mail password resets, for example: ```ini title="frame.conf" FRAME_SMTP_URL='smtps://user:password@smtp.example.com:465' FRAME_SMTP_FROM=frame@example.com ``` ## Commands With the Linux installer, run admin commands as `sudo frame-admin …` (for example `sudo frame-admin user list`; `frame-admin` runs `frame-server admin …` as the service's user). With Docker, use `docker compose exec frame frame-server …`. ```text title="frame-server" frame-server [serve] [--config FILE] run the server (default command) frame-server dev dev server on :8787, ./server/.devdata, seeded demo users frame-server admin setup-link print a one-time link to create the first admin (also printed at first start) frame-server admin user list frame-server admin user create --email E | --phone P [--name N] [--username U] [--admin] (prints a one-time password) frame-server admin user promote make an existing user an admin of the web panel frame-server admin user demote (refuses to remove the last admin) frame-server admin user reset-2fa turn off an admin's two-factor authentication frame-server admin user reset-password (prints a one-time password) frame-server admin user unlink apple|google (removes a linked sign-in, signs out all devices) frame-server admin user delete frame-server admin invite create (prints that user's friend-invite link) frame-server admin invite server [--uses N] [--days D] [--note TEXT] (prints a server invite link; --days 0 = never expires) frame-server backup online, consistent SQLite backup frame-server version ``` Dev mode differs on purpose: it opens registration, turns push off and seeds `alice` (password `password123`) as an admin instead of printing a setup link. See [Try it locally](https://crow.sg/apps/frame/docs/install-binary/#dev). --- # Security > Frame's security model for server operators: who is trusted, what the server protects, how the installer and Docker harden it, what Frame deliberately doesn't do, and a checklist for your server. Source: https://crow.sg/apps/frame/docs/security/ Frame is built to be secure by being small: few moving parts, few dependencies, and nothing that parses untrusted media on the server. This page is the operator's summary; the full model is in [SECURITY.md](https://github.com/Crow-Tech-Pte-Ltd/frame/blob/main/docs/SECURITY.md). ## Who is trusted | Party | Trusted? | What they can see or do | |---|---|---| | **You, the server operator** | Yes, fully | Everything stored on your server: photos, videos, captions, messages, reactions, friend lists, e-mail addresses, phone numbers and IP addresses. That's the self-hosting deal. | | Other users on the same server | No | Only moments their friends addressed to them, chats with friends, and public profiles. | | Network attackers | No | With HTTPS, endpoints, timing and sizes only. | | The push relay (Crow Tech) | No | Opaque relay tokens, ciphertext sizes, timing and which server IPs push. No content, no user identities. | | Apple and Google push services | No | Device tokens, timing, ciphertext and the generic text "Frame: New activity". | | R2 or another bucket provider | No | Encrypted objects, their sizes and access times. | | A tunnel or CDN (such as Cloudflare Tunnel) | Partially | It terminates TLS, so it can technically see the traffic. Built-in HTTPS avoids this. | ## What the server does - **HTTPS** for anything on the internet, from Frame itself (Let's Encrypt), Caddy or a tunnel. The apps refuse plain `http://` except for local network addresses. - **Passwords** are hashed with Argon2id. Sign-in is rate limited per IP and per account, and an unknown account takes as long to reject as a wrong password, so sign-in can't be used to find accounts. - **Apple and Google sign-ins are bound to your server's name** (`FRAME_PUBLIC_URL`) and work once, so a token someone obtained for another Frame server can't be replayed against yours. - **Sessions** are random tokens stored only as hashes, revocable per device. Changing a password signs out every other session. - **Authorisation:** every object is checked against the requesting user. Things you can't see answer `404`, not `403`, so their existence isn't revealed. - **No server-side media processing.** The server checks file types and sizes and stores the bytes; it never decodes images or video. Thumbnails are made on the phone, and the apps strip location and other metadata before upload. - **Media URLs** are signed and expire after 24 hours, and responses tell shared caches not to keep copies. - **Storage encryption:** with R2 or S3, every object is encrypted on the server before upload, with a key in `secrets.json`. - **Push** payloads are end-to-end encrypted between your server and each phone ([Push notifications](https://crow.sg/apps/frame/docs/push/)). - **Logs** never contain passwords, tokens or message text, and Frame's access log records route patterns, not paths or query strings. Invite codes are part of the path, so a reverse proxy's or tunnel's own access log does contain them. - **Few dependencies:** the server uses the Go standard library plus a pure-Go SQLite and `golang.org/x/crypto`; no web framework, ORM or JWT library. Releases ship `SHA256SUMS`, and the installer refuses a file whose checksum doesn't match. ## Hardening **Linux installer** (`frame.service`): a throwaway system user (`DynamicUser`) that can write only its data directory; `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `PrivateDevices`; `NoNewPrivileges` and no capabilities, except binding ports 80/443 when `FRAME_DOMAIN` needs them; `MemoryDenyWriteExecute`, restricted address families and namespaces, a system-call filter and `UMask=0077`. The config file is root-only and read by systemd before privileges are dropped. **Docker:** a distroless image with no shell or package manager, running as uid 65532 on a read-only root filesystem with all capabilities dropped and `no-new-privileges`. In the Caddy setup, the Frame container publishes no ports. ## What Frame deliberately doesn't do - **No end-to-end encryption of photos and messages against the operator.** You can read everything on your server. It keeps the system simple, fast and recoverable; your friends are trusting you. - **Metadata is visible to the operator:** who is friends with whom, who posted when and to whom, reactions, views, read receipts and IP addresses. - **Recipients can keep what you send:** screenshots, saved photos and widget caches can't be prevented. - **A signed media URL works for anyone who has it** until it expires (24 hours). Deleting a moment deletes the file, which ends access immediately. - **Deletion is best-effort beyond the server:** earlier backups still contain deleted content. - **No automatic moderation or content scanning.** Members can block people and report content to you, the operator; nothing goes to Crow Tech or anyone else. - **E-mail addresses and phone numbers aren't verified** (Frame sends no SMS). On an `open` server someone could register another person's number first; invite-only servers, the default, make this practically impossible. - **A small server can be overwhelmed.** Rate limits protect against abuse, not volumetric attacks. - **Servers don't talk to each other.** A user on one server can't see another server's content. ## Checklist for your server - [ ] HTTPS works: `https://your-domain/healthz` shows a valid lock. - [ ] `FRAME_PUBLIC_URL` is set to your address(es). - [ ] `FRAME_TRUSTED_PROXIES` matches your setup: unset with built-in HTTPS, only your proxy's address otherwise. - [ ] Registration is `invite` (the default) or `closed`, unless you really want an open server. - [ ] You created your admin account with the setup link and turned on two-factor authentication in the panel (**Security**). - [ ] Consider `FRAME_ADMIN=local` or `FRAME_ADMIN_LISTEN` with an SSH tunnel if you don't need the admin panel on the internet ([Keep the admin panel private](https://crow.sg/apps/frame/docs/private-admin/)). That also turns off Server admin in the apps, unless you set `FRAME_ADMIN_APP=on`. - [ ] With `FRAME_ADMIN=local`, a reverse proxy or tunnel on the same host is listed in `FRAME_TRUSTED_PROXIES`. - [ ] Only ports 22, 80 and 443 are open (or none, with a tunnel). You log in with SSH keys, not passwords. - [ ] Automatic OS security updates are on (`sudo apt install unattended-upgrades`). - [ ] Nightly backups of the database **and `secrets.json`** are copied off the server, and you've tried a restore once. - [ ] `frame.conf` stays private (`chmod 600` with Docker) and is never committed or shared: it can contain R2 and SMTP credentials. - [ ] You upgrade when a release is announced. ## Report a vulnerability Please report security issues privately through **GitHub private vulnerability reporting** on [`Crow-Tech-Pte-Ltd/frame`](https://github.com/Crow-Tech-Pte-Ltd/frame/security/advisories/new) (Security tab → Report a vulnerability), or by e-mail to `hello@crow.sg` with the subject "Security". Don't open public issues for vulnerabilities. We aim to acknowledge reports within 3 working days. Test only against servers you run yourself. --- # Troubleshooting > Fix common Frame server problems: the app can't connect, invites don't work, HTTPS or certificate errors, failed uploads, slow live updates, rate limits, missing Apple or Google sign-in and notifications. Source: https://crow.sg/apps/frame/docs/troubleshooting/ First look at the server's own log: `journalctl -u frame -n 100` with the Linux installer, or `docker compose logs --tail 100 frame` with Docker. `systemctl status frame` tells you whether it is running. ## The app says it can't connect 1. On your phone's browser, open `https://your-domain/api/v1/server`. You should see JSON with your server's name. 2. If the page doesn't load: - DNS: does `dig +short your-domain` show your server's IP? - Firewall: `sudo ufw status` should allow 80 and 443. Check the provider's firewall too. - Service: run `systemctl status frame` and `journalctl -u frame -n 50`. - Tunnel: is `cloudflared` running (`systemctl status cloudflared`), and is the public hostname set to `localhost:8080` (or `http://frame:8080` with Docker)? 3. In the app, type the address without `https://` and without a trailing slash, such as `frame.example.com`. The app adds the rest. ## A friend can't sign up "Invite required" or "invite invalid": the server is invite-only. Send them a link from **Invites → New invite** in the admin panel, or have a member share theirs from the app. "Invalid" means the link was revoked, expired or used up (server invites are single-use by default), or that **Members can invite** is off and they used a member's link. Check **Settings** and **Invites** in the panel. ## Lost the setup link, or locked out of the panel Run `sudo frame-admin setup-link` while no admin exists. Otherwise: `sudo frame-admin user reset-password ` for a forgotten password, `sudo frame-admin user reset-2fa ` for a lost authenticator, or `sudo frame-admin user promote ` to make an existing account an admin. With Docker, use `docker compose exec frame frame-server admin …`. ## HTTPS or certificate errors - With `FRAME_DOMAIN`, Let's Encrypt must reach ports **80 and 443** on your server directly. If the DNS record is proxied by Cloudflare (orange cloud), switch it to **DNS only**, or use a [Cloudflare Tunnel](https://crow.sg/apps/frame/docs/install-systemd/#cloudflare-tunnel) instead. - Did you run `sudo sh install.sh --reconfigure` after setting `FRAME_DOMAIN`? Without it, Frame can't bind ports 80/443, and the log shows "permission denied". - Let's Encrypt rate limits failed attempts. If you retried many times, wait an hour. - The apps reject self-signed certificates. Use a real domain. ## Uploads fail or videos won't send A proxy in front is probably limiting the request size. Caddy: `request_body max_size 64MB`. nginx: `client_max_body_size 64m`. Cloudflare's free plan: 100 MB at most. Also check `FRAME_MAX_UPLOAD_MB`. ## New messages arrive slowly behind a proxy The proxy is buffering `/api/v1/events`, a Server-Sent Events stream. Turn buffering off for that path (nginx: `proxy_buffering off;` and `proxy_read_timeout 1h;`); see [Behind Caddy, nginx or another proxy](https://crow.sg/apps/frame/docs/install-systemd/#reverse-proxy). ## Everyone gets "too many requests" Or rate limits hit the wrong people: Frame sees your proxy's IP instead of the visitors'. Set `FRAME_TRUSTED_PROXIES` to match your setup: | Setup | Value | |---|---| | cloudflared on the same host | `cloudflare,127.0.0.1/32` | | Caddy or nginx on the same host | `127.0.0.1/32,::1/128` | | Docker with Caddy | preset in the compose file | | Docker with a tunnel or a host proxy | the (b) or (d) line in `docker-compose.yml` | `cloudflare` on its own trusts nobody: it must be paired with the address the tunnel connects from. ## Sign in with Apple or Google is missing Set `FRAME_PUBLIC_URL` to the exact address people type into the app, then restart. If people use more than one address, list them all, separated by commas. ## Notifications don't arrive - Check that `FRAME_PUSH_RELAY` isn't empty. - Check that the server can reach the relay: `curl -sI https://frame.crow.sg/healthz`. - On the phone, allow notifications for Frame. On Android, also turn off battery optimisation for Frame. On iOS, check Focus modes. - Look for `push failed` in the logs. ## "Permission denied" or "readonly database" after admin commands You probably ran `frame-server` directly as root, which left root-owned files in the data directory. Give them back to the service and restart, and from then on use `sudo frame-admin …`: ```sh sudo chown -R --reference=/var/lib/private/frame /var/lib/private/frame && sudo systemctl restart frame ``` ## Testing on your computer, an emulator or the local network Use `http://10.0.2.2:8080` in the Android emulator, `http://localhost:8080` in the iOS simulator, and your computer's LAN address (such as `http://192.168.1.20:8080`) from a phone on the same Wi-Fi, with `FRAME_LISTEN` not set to `127.0.0.1`. Dev mode uses port 8787. See [Try it locally](https://crow.sg/apps/frame/docs/install-binary/#dev). Still stuck? Ask in the [GitHub repository](https://github.com/Crow-Tech-Pte-Ltd/frame/issues), or write to `hello@crow.sg`.