Set up
Install the frame server
On this page
- What you need
- Layout
- HTTPS is required
- Step 1: Copy the files
- Step 2: Install the library
- Step 3: Set the permissions
- Step 4: Write the config
- Step 5: Configure Apache
- Step 6: Create the database
- Step 7: Create your admin login
- Step 8: Start the fetcher
- Step 9: Check that it works
- Step 10: Sign in and add your sources
- PHP settings
- Update the server
- Back up the server
- How permissions work
- Related
This guide is for people who can run a PHP site on Apache and edit a crontab. When you finish, you have a server that frames connect to and an admin page where you claim frames and choose what they show. You do not need a frame to follow it.
The server is plain PHP and SQLite. It has no framework and no database server. You need an HTTPS address for it: the kiosk app refuses plain HTTP.
This page also covers the server's settings, updates, backups and file permissions. Photos, admin logins, and kiosk updates have their own pages: Photos, Use the admin and Update the kiosk.
What you need
- A Linux server (or a small VPS) with Apache 2.4 and a valid HTTPS certificate on a host name.
- PHP 8.5 with these extensions:
pdo_sqlite,curl,mbstring,gd(with JPEG and PNG),dom,xmlreader,xmlwriterandzlib. PHP 8.5 is the tested version. The extension list comes from reading the code, so a missing one shows up as an error in the PHP log. - Optional extensions:
openssl, which you need only if you setkiosk_release_pubkey;pcntl, which letsbin/user.phprestore your terminal if you press Ctrl+C at a password prompt; and PHP built with Argon2 support. Without Argon2 the server falls back to bcrypt for admin passwords, which accepts passwords of at most 72 bytes. - Optional, for the local photo library only:
bubblewrap,libheif-examplesandlibheif-plugin-libde265(for iPhone photos),prlimitandnice. Photos lists the checks. - Composer to install one library (
sabre/vobject, for calendars). - A user that runs cron jobs (called the cron user below) and the web server's user (
www-dataon Debian and Ubuntu). sudorights for creating directories and running commands as the web user.
Apache needs these modules: mod_alias, mod_env, mod_dir and mod_setenvif, and a way to run PHP (mod_php or PHP-FPM). On Debian and Ubuntu:
sudo a2enmod alias env dir setenvif
The server must answer at the path
/frames/
of your host name, for example https://frames.example.com/frames/. The path /frames is fixed in the code.
Layout
The guide uses /srv/frame as the install root. Use another path if you like.
/srv/frame/
app/ the PHP code (from web/app)
public/ the web root for /frames/ (from web/public)
var/ frames.db, uploaded card art (notes/), kiosk updates (kiosk/), the photo library (photos/), admin sessions, device.key
cache/ cache.db, written by the fetcher
vendor/ Composer packages
config.php your settings
Only public/ is reachable from the web.
HTTPS is required
The admin holds passwords and a session cookie, so it works only over HTTPS. Three things enforce it:
- The admin refuses to run (HTTP 500) unless
frames_urlstarts withhttps://. - The admin's cookies use the
__Host-prefix. Browsers accept those only over HTTPS, with noDomainsetting and the path/. - The kiosk app refuses plain HTTP.
A dedicated host name (frames.example.com rather than a path on a site that also runs other applications) is the safest set-up. Nothing else on the host can then read or overwrite the admin's cookies.
If your certificate comes from Let's Encrypt, the frame needs one extra step because Android 6 does not trust Let's Encrypt. See Provisioning, step 4.
If a proxy or CDN sits in front of Apache, read client addresses behind a proxy below. The proxy must pass the original Host header through, or the admin login fails. See Troubleshooting.
Step 1: Copy the files
On the server, as the cron user, create the install root and make it yours:
sudo install -d -o $USER -g $USER /srv/frame
Then on your workstation, from the repository:
rsync -rlpt --chmod=D2750,F0640 --chown=<cron user>:www-data web/app/ you@server:/srv/frame/app/
rsync -rlpt --chmod=D2750,F0640 --chown=<cron user>:www-data web/public/ you@server:/srv/frame/public/
scp web/composer.json web/composer.lock web/config.example.php you@server:/srv/frame/
Do not use rsync -a. It copies your local owner and permissions, which breaks the model in Step 3. The options above set the modes to 0640 for files and 2750 for folders, and the group to the web user's group. Keep the p: without it rsync drops the setgid bit, and folders end up 0750.
--chown needs root on the server. As any other user, rsync skips it without an error, so check with ls -l /srv/frame/app. If the group is not www-data, run this on the server:
sudo chgrp -R www-data /srv/frame/app /srv/frame/public
Do not add -A or -X. Existing ACLs stay as they are, and new files in app/widgets/photos/ inherit the folder's default ACL, so the photo decoder user keeps read access.
Step 2: Install the library
On the server, as the cron user:
cd /srv/frame
composer install --no-dev --optimize-autoloader
chmod 700 vendor
chmod 0640 composer.json composer.lock config.example.php
Only the cron job loads vendor/. The web server never needs it.
Step 3: Set the permissions
Two users touch the data. The web user writes frames.db. The cron user writes cache.db. Each reads the other's file through the group. The setgid bit (2) in the mode keeps new files in the right group.
Replace $USER with your cron user and www-data with your web user if they differ:
sudo install -d -o www-data -g $USER -m 2750 /srv/frame/var
sudo install -d -o $USER -g www-data -m 2750 /srv/frame/cache
The files this guide creates have these owners and modes:
| Path | Owner : group | Mode | Why |
|---|---|---|---|
var/ |
www-data : cron user |
2750 | The web user writes. The cron user reads. |
cache/ |
cron user : www-data |
2750 | The cron user writes. The web user reads. |
var/photos/ |
www-data : cron user |
2750 | Library photos and the upload work area. Only the web user writes. |
app/, public/ |
cron user : www-data |
2750 folders, 0640 files | The web user reads. The cron user reads and updates. Set by the Step 1 rsync options. |
config.php |
cron user : www-data |
0640 | Both read it. |
vendor/ |
cron user | 0700 | Only cron loads it. |
Keep cache/ read-only for the web user. Do not make it group-writable.
How permissions work explains the model.
Step 4: Write the config
-
Copy the example and open it:
cd /srv/frame cp config.example.php config.php sudo chgrp www-data config.php chmod 0640 config.php -
Set at least
frames_urlandowner_name. The configuration reference explains every key. -
Check the file as the web user. A mistake shows as an error that names the key:
sudo -u www-data php -r 'require "/srv/frame/app/bootstrap.php"; echo "ok\n";'You expect
ok. A bad value printsframe: config error:followed by the problem.
Configuration reference
config.php returns a PHP array. It holds no secrets: every secret lives in the database or in files under var/. Unknown keys are an error, so a typo is caught the first time the file loads.
| Key | Required | Type and default | What it does |
|---|---|---|---|
frames_url |
Yes | Text. No default. | The public address, ending in /frames/. It must start with https://. Example: https://frames.example.com/frames/. It must have no query or fragment. The kiosk's FRAME_SERVER_URL must match it. |
owner_name |
Yes | Text, up to 40 characters. No default. | Your name, shown to people who use your links. For example, "Ask Alex for a new link". No <, >, & or quote marks. |
user_agent |
No | Printable ASCII text. Default photo-frame/1.0. |
Sent with every download the server makes. Put a contact URL in it. |
calendar_hosts |
No | List of lowercase host names. Default empty. | The only hosts calendars may be downloaded from. HTTPS only. |
photo_hosts |
No | List of lowercase host names. Default empty. | The only hosts Nextcloud photo albums may be loaded from. HTTPS only. |
client_ip_header |
No | An HTTP header name, such as CF-Connecting-IP. Default none. |
Use it only behind a reverse proxy or CDN. It names the header that carries the real client address. |
trusted_proxies |
No | List of CIDR ranges and the preset name 'cloudflare'. Default empty. |
The proxies allowed to set client_ip_header. The header is believed only when the connection comes from a listed proxy. Setting the header without proxies is an error. |
hsts |
No | true or false. Default false. |
true makes the admin send Strict-Transport-Security: max-age=31536000. Turn it on once you are sure the whole host name stays on HTTPS. |
kiosk_release_pubkey |
No | PEM text of an EC P-256 public key. Default none. | When set, an uploaded kiosk release whose signature does not verify is rejected. Needs the PHP openssl extension. See Update the kiosk. |
var_dir |
No | Absolute path. Default var/ next to app/. |
Where frames.db, photos, card art and kiosk releases live. |
cache_dir |
No | Absolute path. Default cache/ next to app/. |
Where the fetcher writes cache.db. |
widgets |
No | Map of widget ID to options. Default empty. | Per-widget options. The photos widget takes the limits and the sandbox settings in Photos. |
The server fetches nothing from a host that is not in calendar_hosts or photo_hosts. Add the host of the cloud service where your calendar and photo album live, for example 'cloud.example.org'.
A minimal file:
<?php
declare(strict_types=1);
return [
'frames_url' => 'https://frames.example.com/frames/',
'owner_name' => 'Alex',
'user_agent' => 'photo-frame/1.0 (+https://frames.example.com)',
'calendar_hosts' => ['cloud.example.org'],
'photo_hosts' => ['cloud.example.org'],
];
Client addresses behind a proxy
If the server sits behind a proxy, set client_ip_header (for example 'CF-Connecting-IP') and trusted_proxies (CIDR ranges, or the preset 'cloudflare'):
'client_ip_header' => 'CF-Connecting-IP',
'trusted_proxies' => ['cloudflare'],
Behind a proxy with no client_ip_header, every visitor looks like the proxy's address. The admin's per-address login limit (10 failed sign-ins in 15 minutes) then counts everyone together, so a few failed attempts lock the login for all. That fails closed and is safe, but set the header so one bad client cannot lock you out.
The cloudflare preset lists Cloudflare's published address ranges at the time of the release. If Cloudflare changes them, update the list by hand.
What a config error looks like
On the web, a bad config answers HTTP 500 with {"error":"server not configured","code":"not_configured"}. The reason is in the PHP error log on a line that starts frame: config error:. The command-line tools print the same line on standard error.
Step 5: Configure Apache
Add this to the virtual host that serves your HTTPS site. Change the paths if you used a different root:
SetEnv FRAME_APP_DIR /srv/frame/app
Alias /frames /srv/frame/public
<Directory /srv/frame/public>
AllowOverride AuthConfig FileInfo Indexes Options
Require all granted
</Directory>
The .htaccess files in public/ need those four override classes. Without any one of them Apache answers 500:
AuthConfig: the rules that block stray files.FileInfo: passes the frame'sAuthorizationheader to PHP.Indexes:DirectoryIndexand the fallback toindex.php.Options: turns off directory listings.
If you cannot allow .htaccess, copy the rules from public/.htaccess and public/admin/.htaccess into the virtual host. That includes the <If> blocks that raise the request-size limit for uploads (see PHP settings). PHP files must run through your PHP handler in the aliased directory. Then reload Apache:
sudo apachectl configtest && sudo systemctl reload apache2
If app/ sits next to public/, SetEnv FRAME_APP_DIR is optional. Set it when app/ is elsewhere.
What the .htaccess files do
| File | Effect |
|---|---|
public/.htaccess |
Copies the Authorization header into the environment so PHP sees the frame's key. Turns off directory listings. Sends every unknown path to /frames/index.php. Limits request bodies to 64 KiB, with larger limits for two upload addresses (frame screenshots and drop link photos). Denies direct access to any file that is not index.php and has an extension such as php, sql, db, json, log, phar or bak. |
public/admin/.htaccess |
The same, with the fallback /frames/admin/index.php and a 16 KiB default limit. Larger limits for card art, kiosk releases and admin photo uploads. It also holds a commented-out block that refuses plain HTTP at the origin. |
The optional block in public/admin/.htaccess is safe only when Apache itself sees TLS. Behind a CDN or proxy that talks HTTP to the origin, use the X-Forwarded-Proto variant described in the file, or you lock yourself out.
Step 6: Create the database
Run the migration as the web user, so frames.db belongs to it:
sudo -u www-data php /srv/frame/app/bin/migrate.php
You see applied 1, applied 2 and so on, ending with schema at 14 (the number grows with each new migration). Run the migration again after every update of app/: it applies only what is new.
Until the migration has run:
- On a fresh install there is no database, so requests fail with an internal error (HTTP 500).
- After you deploy a release that needs a newer schema, requests answer
503 maintenance.
After the schema line, the command may print WARNING (photos): ... or NOTE (photos): ... lines on standard error. They tell you whether the photo library's requirements are met. They do not stop the migration and do not affect frames, Nextcloud sources or anything else. Only uploading photos is switched off until you fix a warning. Photos explains each line.
Check the owner:
ls -l /srv/frame/var/
frames.db should be -rw-r----- www-data <cron user>.
Step 7: Create your admin login
Nobody can sign in to the admin until a user exists. Create yours right after the migration, as the web user:
sudo -u www-data php /srv/frame/app/bin/user.php add yourname
It asks for the password twice and does not show it. The password must be at least 12 characters. Use the admin covers the password rules, two-step sign-in, and the other user.php commands.
Step 8: Start the fetcher
One cron job downloads weather, calendars and Nextcloud photo lists. The web server itself never makes outbound requests.
-
Run it once by hand, as the cron user, to see errors. It runs for about 45 seconds:
php /srv/frame/app/fetch/fetch.phpIt prints nothing when all is well. Afterwards
ls -l /srv/frame/cache/showscache.dbandfetch.lock. -
Add the cron line with
crontab -e:* * * * * /usr/bin/timeout -s KILL 58 /usr/bin/php /srv/frame/app/fetch/fetch.php >/dev/nullThe
timeoutkills a run that hangs on a bad calendar, so it never overlaps the next run. Check yourphppath withcommand -v php.
The fetcher only downloads what an enabled widget on a claimed frame asks for. A server with no claimed frames, or with frames that use no weather, calendars or Nextcloud albums, leaves it idle. How often each source is refreshed is in Widgets.
Step 9: Check that it works
Replace the host with yours:
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://frames.example.com/frames/admin/
curl -s -w '\n%{http_code}\n' https://frames.example.com/frames/admin/api/session
curl -s -w '\n%{http_code}\n' https://frames.example.com/frames/api/v1/nope
curl -s -o /dev/null -w '%{http_code}\n' https://frames.example.com/frames/
You expect:
303to the login page (.../frames/admin/login).- A JSON body with
"code":"auth_required"and401. - A JSON body with
"code":"not_found"and404. 401, because frames need their own key.
Then confirm the server refuses to run stray PHP files. Create throwaway files, test, and delete them:
cd /srv/frame/public
echo '<?php echo 1;' > x.php && cp x.php x.phar
curl -s -o /dev/null -w '%{http_code}\n' https://frames.example.com/frames/x.php
curl -s -o /dev/null -w '%{http_code}\n' https://frames.example.com/frames/x.phar
rm -f x.php x.phar
Both must print 403. A 200 or 500 means the .htaccess rules are not active. Fix this before you leave the site up.
Step 10: Sign in and add your sources
Open https://frames.example.com/frames/admin/ and sign in with the user you made. The top navigation has Frames, Library, Photos and Card designs. A frame appears under Frames after its kiosk enrolls. Provisioning covers claiming one.
Before you claim a frame, you can add what it will show:
- Weather. In Library, add a location. See Widgets: Weather.
- Calendars. In Library, add a calendar share link. See Widgets: Calendar.
- Photos. Link a Nextcloud album or build a local library. See Photos.
- Cards. Upload covers and stamps in Card designs. See Widgets: Cards.
- Home Assistant. You set it per frame. See Widgets: Home Assistant.
PHP settings
Set these in the PHP that serves the web app (the php.ini for Apache's PHP or PHP-FPM):
| Setting | Value | Why |
|---|---|---|
post_max_size |
At least 31M |
A photo can be 30 MiB. The app refuses larger ones itself. |
upload_max_filesize |
At least 31M |
The same reason. |
Uploads are sent as raw request bodies, not as forms, and have two limits of their own:
LimitRequestBodyin the.htaccessfiles. They raise it to the right size for each upload address only. They match on%{THE_REQUEST}, because Apache evaluates the limit on an internal subrequest whereREQUEST_URIis the front controller. If you copied the rules into the virtual host instead, copy the<If>blocks too. Everything else keeps its small limit.- The size the app itself enforces.
| Upload | .htaccess limit |
App limit |
|---|---|---|
| Photo in the admin | 30 MiB | upload_bytes, 30 MiB by default |
| Photo from a drop link | 30 MiB | upload_bytes, 30 MiB by default |
| Card cover or stamp | 4 MiB | 4 MiB |
| Kiosk release (APK) | 16 MiB | 16 MiB |
| Frame screenshot | 600 KiB (614,400 bytes) | 600 KiB |
| Everything else in the admin | 16 KiB | none |
| Everything else for frames and phones | 64 KiB | none |
Set post_max_size to at least 17M if you upload kiosk releases and never upload photos. If you raise upload_bytes, raise LimitRequestBody in both .htaccess exceptions and post_max_size to match.
Update the server
-
Copy the new
app/andpublic/over the old ones withrsync. Do not overwriteconfig.php.rsync -rlpt --chmod=D2750,F0640 --chown=<cron user>:www-data web/app/ you@server:/srv/frame/app/ rsync -rlpt --chmod=D2750,F0640 --chown=<cron user>:www-data web/public/ you@server:/srv/frame/public/Use the same rsync options as in Step 1. Files that a release removed stay behind. Add
--deleteto both commands to remove them. That is safe whenapp/andpublic/hold only code you copied from the repository. -
If
web/composer.lockchanged, copy it andcomposer.jsonand runcomposer install --no-dev --optimize-autoloaderagain in/srv/frame. -
Run the migration again as the web user:
sudo -u www-data php /srv/frame/app/bin/migrate.phpUntil it finishes, requests answer
503 maintenance. -
Read what the migration prints. New
WARNINGlines mean something the photo library needs has changed. -
If you use the photo decoder user (
'sandbox' => 'user') and the release changedapp/widgets/photos/bin/frame-photo-run, install the new copy. See Photos, sandbox mode user. -
Tell the frames to reload:
touch /srv/frame/app/bootstrap.phpFrames reload when the newest modification time under
app/changes. rsync keeps the source file times, which can be older than the server's, so run this after each update.
Updating the server does not update the kiosk app on the frames. For that, see Update the kiosk.
Fixing an older install's permissions
If you installed before the Step 1 options existed, or --chown could not run, set the group and modes by hand:
sudo chgrp -R www-data /srv/frame/app /srv/frame/public && sudo find /srv/frame/app /srv/frame/public -type d -exec chmod 2750 {} + && sudo find /srv/frame/app /srv/frame/public -type f -exec chmod 0640 {} +
If you use the photo decoder user, run the find ... -print check from Photos afterward.
Back up the server
Back up these. Without them, a restored database shows blank photos, blank cards, or no kiosk releases.
| What | Where | Why |
|---|---|---|
| The database | var/frames.db |
Frames, widget settings, admin users, links (as hashes), cards, album and photo records, kiosk release records. |
| The photo library | var/photos/ |
The stored photos. The server keeps no originals, so these files are the only copy. Skip var/photos/tmp/. |
| Card art | var/notes/ |
Covers and stamps. A database restored without these leaves cards with blank covers. |
| Kiosk releases | var/kiosk/ |
The uploaded APKs. |
| Your settings | config.php |
Your host names and options. |
Take the database with SQLite's own backup command, so you never copy a file in the middle of a write. It needs the sqlite3 command-line tool (sudo apt install sqlite3) and a destination folder the web user can write to:
sudo -u www-data sqlite3 /srv/frame/var/frames.db ".backup '/path/to/backup/frames-$(date +%F).db'"
Note
The .backup command is standard SQLite and was not run as part of writing this page.
You do not need to back up these:
cache/. The fetcher rebuilds it.var/sessions/. Admins sign in again.var/device.key. Deleting it makes every trusted-device cookie invalid. Nothing else changes.var/photos/tmp/. Work files.
Warning
These secrets are never on the server and cannot be restored from a server backup: the APK keystore (~/.android/frame-kiosk.keystore by default) and the release key (~/.android/frame-release-key.pem). They live on the computer where you build the kiosk. Back them up somewhere private. See Update the kiosk.
To restore: put the files back, then run the migration as the web user.
Drop links carry a secret in the address, so they appear in your web server's access log. Rotate or delete logs on a short schedule, keep them readable by root only, and revoke any link you suspect. See Create a link and Public links.
How permissions work
The server splits its work between two users so a bug in one cannot damage the other's data.
flowchart LR
W["Web user<br/>www-data"] -- "writes" --> V["var/<br/>frames.db, photos, notes, kiosk, sessions"]
W -- "reads" --> C["cache/<br/>cache.db"]
R["Cron user"] -- "writes" --> C
R -- "reads" --> V
R -- "loads" --> L["vendor/<br/>sabre/vobject"]
- The web user handles every request. It writes
var/and readscache/. It never makes outbound requests and never loadsvendor/. - The cron user runs the fetcher, the only code that makes outbound requests. It writes
cache/and readsvar/through the group. - The setgid bit (the leading
2in2750) makes new files invar/andcache/inherit the folder's group, so the other user can read them. -
config.phpis 0640: owned by the cron user, groupwww-data. No other local user can read it. -
vendor/is 0700. Only the cron user needs it. -
Nothing in
var/is reachable from the web. The app refuses to start the photo library ifvar_diris inside the document root. - The photo decoder can run as a third, powerless user. Photos adds ACLs and a firewall rule for it.
Code in app/ and public/ is owned by the cron user, with group www-data, mode 0640 for files and 2750 for folders. Other local users cannot read it. The photo decoder user's ACLs are designed for these modes.
Note
This was not verified on every host. If you use the decoder user sandbox, run the find ... -print check from Photos after a deploy.
Related
Next: Use the admin.