The Frame server is open source under AGPL-3.0, on GitHub. This page is the short version. The repository has the full guide, every setting and the security model.
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.
- 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. - Docker with Compose on an amd64 or arm64 machine for recipes 1 and 2 (on a Raspberry Pi, a 64-bit OS), or systemd on an amd64, arm64 or armv7 machine for recipe 3.
| You have… | Use | HTTPS by |
|---|---|---|
| A server with a public IP and Docker | Recipe 1: Docker + Caddy | Caddy, automatically |
| A home computer or Pi, and no open ports | Recipe 2: Docker + Cloudflare Tunnel | Cloudflare |
| A Linux server without Docker | Recipe 3: installer + systemd | Frame itself (Let’s Encrypt) |
- A server with a public IP and DockerHTTPS by: Caddy, automatically
- A home computer or Pi, and no open portsHTTPS by: Cloudflare
- A Linux server without DockerHTTPS by: Frame itself (Let’s Encrypt)
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. Frame also uses it in invite links. Every recipe below sets it.
Recipe 1: Docker Compose with Caddy
Caddy sits in front of Frame and gets HTTPS certificates for your domain automatically. Use this on a server with a public IP address.
Point your domain at the server
At your DNS provider, add an
Arecord (and optionallyAAAA) forframewith 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.Get the files and write the config
Terminalgit clone https://github.com/Crow-Tech-Pte-Ltd/frame.gitcd frame/deploycp frame.conf.example frame.confnano frame.conf # set FRAME_NAME; leave FRAME_DOMAIN unsetecho "FRAME_HOSTNAME=frame.example.com" > .envThe Caddy compose file sets
FRAME_PUBLIC_URLandFRAME_TRUSTED_PROXIESfor you fromFRAME_HOSTNAME.Start it
Terminaldocker compose -f docker-compose.caddy.yml up -d # pulls the official imagedocker compose -f docker-compose.caddy.yml logs -f frameOpen
https://frame.example.com/healthz. It should sayok. Then create your admin account (first run).
Using the Caddy file
Every docker compose command for this recipe needs -f docker-compose.caddy.yml, including the admin, backup and update commands below. The image is ghcr.io/crow-tech-pte-ltd/frame-server:0, for amd64 and arm64.
Recipe 2: Docker 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).
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 hostnameframe.example.comwhose service ishttp://frame:8080.Get the files and write the config
Terminalgit clone https://github.com/Crow-Tech-Pte-Ltd/frame.gitcd frame/deploycp frame.conf.example frame.confnano frame.conf # FRAME_PUBLIC_URL=https://frame.example.com and FRAME_NAMEecho "CLOUDFLARE_TUNNEL_TOKEN=<your tunnel token>" > .envSwitch docker-compose.yml to the tunnel
In
docker-compose.yml:- remove the
ports:block of theframeservice; - uncomment the
cloudflaredservice; - 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.
- remove the
Start it
Terminaldocker compose up -d # pulls the official imagedocker compose logs -f frameOn your phone, on mobile data, open
https://frame.example.com/healthz. It should sayok.
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 ends the HTTPS connection, so it can technically see the traffic. If that matters to you, use recipe 1 or 3.
Prefer to build the image yourself?
Both Docker recipes pull the official image. To build it from the repository instead, add the docker-compose.build.yml override:
# recipe 2echo "COMPOSE_FILE=docker-compose.yml:docker-compose.build.yml" >> .envdocker compose up -d --build # recipe 1docker compose -f docker-compose.caddy.yml -f docker-compose.build.yml up -d --buildWith the COMPOSE_FILE line in deploy/.env, later docker compose commands use your own build too. With recipe 1, keep passing both -f options.
Recipe 3: one-line installer with systemd
For Debian, Ubuntu, Raspberry Pi OS, Fedora and other Linux systems with systemd, on amd64, arm64 or armv7. Frame can get its own HTTPS certificate, so you don't need another web server.
Point your domain at the server and open the firewall
Add an
Arecord forframewith the server's IP (DNS only on Cloudflare). Then allow SSH and web traffic, and nothing else:Terminalsudo apt install -y ufw # Debian, Ubuntu, Raspberry Pi OSsudo ufw allow OpenSSHsudo ufw allow 80,443/tcpsudo ufw enableDownload the installer, read it, run it
Terminalcurl -fsSLO https://github.com/Crow-Tech-Pte-Ltd/frame/releases/latest/download/install.shless install.sh # read it (press q to quit)sudo sh install.shThe installer picks the release for your CPU and checks its SHA-256 checksum against the release's
SHA256SUMSbefore installing anything. It installsframe-server, theframe-adminhelper and a locked-down systemd service, and creates/etc/frame/frame.conf. It never touches your data in/var/lib/frame.Configure it
Open
sudo nano /etc/frame/frame.conf, remove the#and fill in:/etc/frame/frame.confFRAME_DOMAIN=frame.example.comFRAME_PUBLIC_URL=https://frame.example.com[email protected]FRAME_NAME="Our Frame"Start it
Terminalsudo sh install.sh --reconfigure # lets Frame use ports 80/443 because FRAME_DOMAIN is setsudo systemctl start framesystemctl status frame # should say "active (running)"Open
https://frame.example.com/healthz. It should sayok.
Behind your own proxy or tunnel
Don't set FRAME_DOMAIN. Set FRAME_LISTEN=127.0.0.1:8080, FRAME_PUBLIC_URL, and FRAME_TRUSTED_PROXIES to match: cloudflare,127.0.0.1/32 for cloudflared on the same machine, or 127.0.0.1/32,::1/128 for Caddy or nginx on the same machine.
First run: create your admin account
On its first start, the server writes a one-time link to its log:
Create your admin account: https://frame.example.com/admin/setup#t=…| Setup | Find the link |
|---|---|
| systemd | sudo journalctl -u frame | grep "admin account" |
| Docker | docker compose logs frame | grep "admin account" |
| Fresh link | sudo frame-admin setup-link or docker compose exec frame frame-server admin setup-link |
- systemd
sudo journalctl -u frame | grep "admin account" - Docker
docker compose logs frame | grep "admin account" - Fresh link
sudo frame-admin setup-linkordocker compose exec frame frame-server admin setup-link
With the Caddy recipe, add -f docker-compose.caddy.yml after docker compose. Open the link 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 /admin/. The link works once and expires after 24 hours.
Then:
- sign in to the Frame app with the same details;
- in the panel, open Security and turn on two-factor authentication;
- open Invites → New invite to get a link and QR code for each friend (one use within 7 days by default).
Keep the admin panel private
By default the panel is served at /adminon the same address as the app. If you don't need it on the internet, serve it only on a local address and reach it over SSH:
FRAME_ADMIN_LISTEN=127.0.0.1:8081The main address then answers /admin with 404. From your laptop:
ssh -N -L 8081:127.0.0.1:8081 you@your-serverThen open http://localhost:8081/admin/. With Docker, publish the admin port on the host's loopback only, with ports: ["127.0.0.1:8081:8081"] and FRAME_ADMIN_LISTEN=:8081.
Other options: FRAME_ADMIN=local answers only to this machine and your trusted proxy networks, and FRAME_ADMIN=off turns the panel off (the command line still works). Keep FRAME_PUBLIC_URLset, so invite links made in the panel don't point at localhost.
Key settings
Settings live in /etc/frame/frame.conf (systemd) or deploy/frame.conf (Docker), one KEY=value per line. Restart after a change: sudo systemctl restart frame or docker compose up -d. The server name, who can join and the upload limits can also be changed in the admin panel, unless they are set in the file.
| Setting | What it does |
|---|---|
FRAME_PUBLIC_URL | The address people type into the app. Needed for Apple and Google sign-in and used in invite links. Several names? Separate them with commas, main one first. |
FRAME_NAME | The server name shown in the app. |
FRAME_REGISTRATION | invite (default): new accounts need an invite. open: anyone who knows the address. closed: only admins create accounts. |
FRAME_USER_INVITES | On invite-only servers, whether members' invite links from the app can bring in new people (default true). |
FRAME_AUTH_METHODS | Sign-in methods offered: any of email, phone, apple, google (default: all four). |
FRAME_PUSH_RELAY | Push relay for notifications. Default https://frame.crow.sg; set it empty to turn notifications off. |
FRAME_SMTP_URL, FRAME_SMTP_FROM | Send password-reset e-mails. Without them, admins reset passwords in the panel. |
FRAME_TRUSTED_PROXIES | Proxies whose visitor-IP headers Frame trusts, so rate limits see real visitors. Leave unset when phones connect directly. |
FRAME_STORAGE=s3, FRAME_S3_* | Store media in Cloudflare R2 or another S3 bucket. Files are encrypted before upload by default. |
FRAME_PUBLIC_URLThe address people type into the app. Needed for Apple and Google sign-in and used in invite links. Several names? Separate them with commas, main one first.FRAME_NAMEThe server name shown in the app.FRAME_REGISTRATIONinvite(default): new accounts need an invite.open: anyone who knows the address.closed: only admins create accounts.FRAME_USER_INVITESOn invite-only servers, whether members' invite links from the app can bring in new people (default true).FRAME_AUTH_METHODSSign-in methods offered: any of email, phone, apple, google (default: all four).FRAME_PUSH_RELAYPush relay for notifications. Defaulthttps://frame.crow.sg; set it empty to turn notifications off.FRAME_SMTP_URL,FRAME_SMTP_FROMSend password-reset e-mails. Without them, admins reset passwords in the panel.FRAME_TRUSTED_PROXIESProxies whose visitor-IP headers Frame trusts, so rate limits see real visitors. Leave unset when phones connect directly.FRAME_STORAGE=s3,FRAME_S3_*Store media in Cloudflare R2 or another S3 bucket. Files are encrypted before upload by default.
For e-mail password resets, for example:
FRAME_SMTP_URL='smtps://user:[email protected]:465'[email protected]Keep frame.conf private: it can contain passwords and keys. Every setting is described in CONFIG.md.
Notifications and the push relay
Apple and Google only deliver notifications signed with the app publisher's keys, so your server hands them to Crow Tech's relay at frame.crow.sg. Each phone makes its own key and shares it only with your server, so the relay, Apple and Google only see encrypted data. The relay stores nothing and never logs tokens, contents or IP addresses.
To turn notifications off, set FRAME_PUSH_RELAY= (empty) and restart. The apps still work: they update live while open, and widgets refresh about every 15 minutes.
If notifications don't arrive:
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"Backups
Back up three things, and copy them off the server:
| What | Why |
|---|---|
The database, made with backup | Accounts, friendships, moments and messages. |
secrets.json | Signing keys and the key that encrypts S3/R2 media. Lose it and that media can't be read. |
media/ (local storage) | The photos and videos. |
- The database, made with
backupAccounts, friendships, moments and messages. secrets.jsonSigning keys and the key that encrypts S3/R2 media. Lose it and that media can't be read.media/(local storage)The photos and videos.
systemd
sudo frame-admin backup /root/frame-$(date +%F).dbsudo tar -C /var/lib/frame -czf /root/frame-files-$(date +%F).tgz secrets.json mediaDocker
docker compose exec frame frame-server backup /data/frame-backup.dbdocker compose cp frame:/data/frame-backup.db ./frame-$(date +%F).dbdocker run --rm -v frame_frame-data:/data:ro -v "$PWD":/backup alpine \ tar -C /data -czf /backup/frame-files-$(date +%F).tgz secrets.json mediaThe database backup is consistent and safe to take while Frame runs. Run it nightly, for example with cron, and try a restore once. Restoring is covered in the full guide.
Updates
Back up first. Database changes run automatically when the new version starts.
systemd
curl -fsSLO https://github.com/Crow-Tech-Pte-Ltd/frame/releases/latest/download/install.shless install.shsudo sh install.sh # or: sudo sh install.sh --version v1.2.3The installer checks the checksum, replaces the program and restarts the service. Your configuration and data are untouched.
Docker
cd frame && git pull # updates the compose filescd deploy && docker compose pull && docker compose up -dIf you build the image yourself, run docker compose up -d --build after git pull instead. The :0 tag follows 0.x releases; pin an exact version in the compose file if you only want to upgrade by hand. To hear about new releases, use Watch → Custom → Releases on the GitHub repository.
Connect the app
- Open Frame. When it asks Where's your Frame?, type your address, such as
frame.example.com, withouthttps://. - Sign in with the account you made with the setup link.
- Invite friends from the app, or from Invitesin the admin panel. With an invite from a member, they're friends as soon as they join.
If the app can't connect, open https://frame.example.com/api/v1/serverin your phone's browser. You should see your server's name. The apps only accept plain http:// for local network addresses, so anything on the internet needs HTTPS.
Full documentation
- Self-hosting guide: more recipes, R2 storage, restoring backups, costs and troubleshooting.
- Configuration reference: every setting and admin command.
- Admin panel: users, invites, settings and two-factor authentication.
- Security model: what Frame protects, and what it deliberately doesn't.
Whoever runs a server can see what is stored on it. Your friends are trusting you with their photos: keep the server updated and backed up.