Set up

Install the frame server

On this page

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, xmlwriter and zlib. 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 set kiosk_release_pubkey; pcntl, which lets bin/user.php restore 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-examples and libheif-plugin-libde265 (for iPhone photos), prlimit and nice. 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-data on Debian and Ubuntu).
  • sudo rights 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_url starts with https://.
  • The admin's cookies use the __Host- prefix. Browsers accept those only over HTTPS, with no Domain setting 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

  1. 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
    
  2. Set at least frames_url and owner_name. The configuration reference explains every key.

  3. 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 prints frame: 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
<?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'):

php
'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's Authorization header to PHP.
  • Indexes: DirectoryIndex and the fallback to index.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.

  1. Run it once by hand, as the cron user, to see errors. It runs for about 45 seconds:

    php /srv/frame/app/fetch/fetch.php
    

    It prints nothing when all is well. Afterwards ls -l /srv/frame/cache/ shows cache.db and fetch.lock.

  2. Add the cron line with crontab -e:

    * * * * * /usr/bin/timeout -s KILL 58 /usr/bin/php /srv/frame/app/fetch/fetch.php >/dev/null
    

    The timeout kills a run that hangs on a bad calendar, so it never overlaps the next run. Check your php path with command -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:

  1. 303 to the login page (.../frames/admin/login).
  2. A JSON body with "code":"auth_required" and 401.
  3. A JSON body with "code":"not_found" and 404.
  4. 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:

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:

  • LimitRequestBody in the .htaccess files. 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 where REQUEST_URI is 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

  1. Copy the new app/ and public/ over the old ones with rsync. Do not overwrite config.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 --delete to both commands to remove them. That is safe when app/ and public/ hold only code you copied from the repository.

  2. If web/composer.lock changed, copy it and composer.json and run composer install --no-dev --optimize-autoloader again in /srv/frame.

  3. Run the migration again as the web user:

    sudo -u www-data php /srv/frame/app/bin/migrate.php
    

    Until it finishes, requests answer 503 maintenance.

  4. Read what the migration prints. New WARNING lines mean something the photo library needs has changed.

  5. If you use the photo decoder user ('sandbox' => 'user') and the release changed app/widgets/photos/bin/frame-photo-run, install the new copy. See Photos, sandbox mode user.

  6. Tell the frames to reload:

    touch /srv/frame/app/bootstrap.php
    

    Frames 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 reads cache/. It never makes outbound requests and never loads vendor/.
  • The cron user runs the fetcher, the only code that makes outbound requests. It writes cache/ and reads var/ through the group.
  • The setgid bit (the leading 2 in 2750) makes new files in var/ and cache/ inherit the folder's group, so the other user can read them.
  • config.php is 0640: owned by the cron user, group www-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 if var_dir is 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.

Next: Use the admin.