I dropped in at the Chamber of Commerce today to catch up. I spotted a few changes since my last visit. First, they had hung up a photo of the Gazebo at the town square that I had made out of an off-brand compatible with Lego, using Brick Me to create instructions and send me 9,216 tiny 1×1 squares, and 9 transparent 32×32 baseplates. It took many days to assemble, but it’s definitely a conversation piece. As a Lego enthusiast, and host of a few Lego Users Group meetings around town, my only regret is that it’s not “real” Lego.

Another new addition was a large TV mounted on the wall. The Chamber has plans to setup a Kiosk to display photos and videos of Ribbon Cuttings, Festivals, and other materials. I enquired if they had anything for the Kiosk itself to choose what is displayed on the television, and the answer was “No”. Well, as a member of the Chamber, let’s see what we can do about that.
First, it’s not a touch screen device, so we don’t need to focus on any interactivity. We just need to serve static content with images, slide shows, and video over HDMI. I can probably set something up with a tiny Raspberry Pi Zero W. Before going that route, let’s see if there are any commercial solutions that are affordable. It’s not always worth building something from scratch if a company has already provided a robust commercial system with their own support.
A quick search, and most kiosk products are for the full system – display, stand, and server. We’ve already got a display, so that eliminates just about everything offered commercially. What I am finding is services for digital signage that works with Google Chromecast, Raspberry Pi, Roku, or the Amazon Fire TV Stick.
- Display NOW Digital Signage Manager (App) – $8 to $11/month
- Arreya Digital Signage Software (Pi & Chromecast) – $75/month
- XOGO Digital Signage – Free for 1 sign/15 items; otherwise $20/month
- OptiSigns Digital Signage – Free (with logo/limitatiosn); otherwise $10, $15, $30, $45/month
Devices
- OptiSigns Digital Signage – $80, $300, $600 (Plus $0 to $45/month)
Terminology – it seems that “Kiosk” is more of an interactive sign that people can touch and interact with, while “Digital Signage” is static content that displays that you can’t interact with: Menu’s, Billboards, etc.
For digital signage, a lot of companies are offering the ability to manage the signage on their own cloud-based services with a monthly/yearly cost. I’m more focused on an upfront cost on hardware that we can have it installed, and then the hardware hosts and manages the content itself internally. Without an Internet connection, the digital content should still be displayed.
Now that I have a grasp on terminology, let’s see if there is any software that offers digital signage on Linux, specifically for ARM based system such as the Raspberry Pi.
- Info-Beamer (Pi Zero, Pi 1 to 5, and 4K on Pi 4 & 5) First device free, 7.75€/month for each additional device. 0.62€/GB/month storage
- piSignage – 2 player license free + $20 subscription/year/player
Well, that’s closer, but I’m looking for a locally managed system that doesn’t have a monthly/yearly fee associated with it.
The PiOSK is such a bare-bones solution, that you can’t really do much with it. At this point, I’m wondering why I don’t just create a simple web page that displays rotating content from an API call to PHP, and a single-page application that lets you manage the content available. This is such a simple thing that I figure most people would need with non-profit organizations. Why are there tons of paid subscription services, and no freemium software to manage the content locally?
Let’s try asking an LLM.
List some free open-source software for a kiosk or digital signage that runs on Linux.
| OpenAI ChatGPT | Google Gemini | Meta AI | Anthropic Claude |
|---|---|---|---|
| Screenly OSE (Open-Source Edition) | Wayland kiosk | InfoBeamer | Screenly OSE (Open-Source Edition) |
| Xibo | Kiosk Mode | Screenly | XiboLinux |
| PiSignage | Webkiosk | Xibo | OpenSplash |
| Concerto | Xibo | Contribute Signage | Info-Beamer |
| OpenKiosk | Open Digital Signage | Open Media Player | PiSignage |
| Chromium in Kiosk Mode | YMP (Yet Another Media Player) | Chromium OS for Kiosks | Yodeck |
| Info-Beamer | Linux Kiosk | MagicMirror | |
| Webconverger | |||
| PcLinuxOS Kiosk Edition | |||
| Kweb suite |
I’ve already seen a few of these, as well as a few that I haven’t already mentioned.
- Info-Beamer – 7.75€/month + storage
- MagicMirror – $0, more for bits of info behind mirrors
- OpenSplash – just the media player
- PiSignage – $20/year
- Screenly
- Screenly OSE (open source edition) – where is this?
- Screenly v2 (closed source) $11-$25/screen/month
- rebranded Anthias (closed source – but its on github?) … Was the open source version renamed, while the paid version remains as Screenly? I’ve never heard of the “free” version of software being rebranded so the closed-source version can take over the name.
- Xibo – $5-13/month (+$28 player license?)
- Yodeck – $8-15/month
I think Anthias (formerly known as Screenly OSE) may be the way to go.
- Anthias Digital Signage Installation mit Raspberry OS Lite + No IP Client – Sounds German, walks through Pi installation with Raspberry Pi OS Lite, setting up Dynamic DNS with no-ip.com, interface of managing Anthias over web at 15:26, but no interaction with it.
- Anthias Open Source Digital Signage fĂĽr Raspberry Pi – another German? video. Interacts with interface at 7:33. Can play mp4 videos, show websites. You specify the duration to display each asset.
I’m not impressed with the back-end code to manage everything. I mean, well – yea. You can set things up through the web interface, but … ugh. Not much in the way of managing anything. As far as finding anything on YouTube – I only found two videos in German that talk about yet, yet they rebranded with the new name two years ago.
I’m getting a big confused here… why is it so hard to find anything that doesn’t have a feee with it, and why is anything that’s open source … Junk? How are small organizations, churches, non-profits, etc affording digital signage for their lobbies? Are they paying an arm & a leg?
I’m wondering if the TV has a Roku… maybe we can set it up with a photo screen saver that just switches photos every X seconds/minutes.
- PhotoView for Google Photos
- Roku Photo Streams – add up to 1,000 photos, can also choose Google Photo albums to include.
The Roku Screensavers are an interesting option that I’ll bring up. Essentially it shows each image for about 30 seconds and fades between them. If the TV already has Roku, it’s a cheap option. However, I think just about everyone that sees it will catch on that it’s just swapping images, and the Chamber will not have control over duration or transitions. In addition, I believe it will be unable to display videos, and certainly not display web pages.
In other news, my Raspberry Pi 5 arrived today. What I could do is put all of my AI research on the back burner and setup the Pi 5 as a Kiosk / Digital Signage server. Given that the display is a 4K monitor, I would need to program on either a Raspberry Pi 4 or Raspberry Pi 5 to offer that kind of resolution over HDMI.
So… before we run off making a custom solution, let’s think about what’s needed.
- Overview
- A web server to serve web pages
- Storage of images, videos
- A way to manage images, videos with uploading and scheduling in a rotation
- Load browser in kiosk mode when device boots up
- Bonus: hardened security to prevent anyone from adding a keyboard via USB/BlueTooth and mucking with the system
- Bonus: system auto-updates
- Bonus: potential for any other TV on the network can load a web page from the web server in Kiosk mode with Amazon Fire Stick, Google Chromcast, Roku, etc. and identify itself with an API key, allowing different content to rotate on other TV’s
- Software
- Linux (Ubuntu Desktop)
- Apache
- PHP
- MySQL or MariaDB database
- SSH
- Hardware ~$136-178
- ~$130-160 Raspberry Pi Kit
- ~$90 Raspberry Pi 4 or 5 (4K HDMI, ~8GB ram)
- ~$7-14 Micro-HDMI to HTML adapter
- ~$10-20 microSD card (32 or 64 GB)
- ~6-12 USB-C to USB-C cable that can support 5 Amps (25 Watts) or more (Some power supplies come with the cable)
- ~$20 25Watt or greater USB-C power supply
- ~$10-17 CPU Fan + Heat Sinks (Active Cooler for Raspberry Pi)
- ~$10-40 Bonus: Case to hold Pi
- ~$6-18 Heavy-Duty Velcro Strips with adhesive
- ~$130-160 Raspberry Pi Kit
Given that I do services and don’t sell physical products, the Chamber will need to buy the hardware themselves, and I can simply install the software onto it. I’ll provide a few links to various kits for a few vendors, as well as a part-out list.
I could setup a simple proof of concept tonight and perhaps visit tomorrow and show how the manager and kiosk would work to see if the Chamber is interested in proceeding forward, as well as any input that they would have over how the content is uploaded & scheduled.
Let’s start off with just setting up an operating system. It looks like the Kit I purchased is already assembled, and the microSD card is also present. It looks like an operation system is already installed (Raspberry Pi Desktop), and in the start up phase of setting up localization and a user account.
Given I don’t have a mouse, this is fairly difficult. Tapping the space bar to mark checkboxes seems to flicker the box on/off a bit quick. In addition, I’m cocking my head to the side as my side monitor is rotated 90 degrees. I switched over to the main external monitor. That fixes my awkward head tilt. I still need to do something about a mouse. Time to search the depths of the deep…
It’s impressive how much stuff you can find when you aren’t looking for it, and how much you can’t find. I ended up finding a VHS tape of a school play I was in from the early 80’s in which I recall being dressed as an 8 of diamonds in Alice in Wonderland. I had actually been reminded of it earlier today as I saw a King and Queen of hards dressed up when handing out candy at the Children’s Halloween Jubilee today. In addition to that, I found two wireless keyboards with trackpads, and a wireless trackball – all of which none had the unifying device. I ended up grabbing the trackball from the computer that I use down there. Use what you got.
Ok… I can’t see much else about this operating system. The start menu has a help option, but nothing that points to an about page for the operating system. Terminal takes forever to start up. The Kinesis Advantage keyboard is having trouble connecting. All lights are lit up for caps/scroll/num/insert lock. I’ve always seemed to have trouble with certain USB ports with this keyboard. I think it’s primarily meant for USB 1.0. I’ve got a USB-C to 3.0 dongle. Let’s try that. Hmm… the only USB-C port on the Pi is already being used for power. Ok… let’s just try rebooting the Pi with the keyboard plugged in. Ugh, the case doesn’t have a power button. Actually… it does. I just noticed it after I unplugged the USB-C cord. For some reason I thought it was a
I got a message:
This power supply is not capable of supplying 5A.
Power to peripherals with be restricted.
Huh? The box says 45W USB-C power supply. 45W divided by 5volts is … 9Amps. Keyboard not working. Let’s switch ports and try that power button. Oh nice – it’s a software power off. The machine logged off rather than cutting power. Ok, the keyboard is working with the 3.0 blue port.
I got the same message again about power. Apparently it’s a configuration issue rather than actually detecting power. I found a fix: Powering the Raspberry Pi 5
sudo rpi-eeprom-config --edit # Add the following line PSU_MAX_CURRENT=5000
Rebooting, and the message no longer displays. However, my keyboard stopped working again. I’m going to see if I can find a USB hub. Ah, that’s better. I found a cheap 10 port hub. It had the same problem, but turning the power switch on/off fixed the problem. It feels like there may be some kind of race condition with the USB host server. Let’s see what OS this is specifically.
uname -a # Linux raspberrypi 6.6.20+rpt-rpi-2712 #1 SMP PREEMPT Debian 1:6.6.20-1+rpt1 (2024-03-07) aarch64 GNU/Linux
Ok, this OS is bugging the heck out of me. I need something a bit more robust. Let’s see what Ubuntu offers at the moment for desktop Pi: Install Ubuntu on a Raspberry Pi
Ubuntu 24.04.1 LTS: Minimum 4GBs of RAM and 16GB storage required. The image is 2.4GB, which is about a 4 minute download over WiFi. One of these days I’m going to run a wire down to the basement and connect directly to the router to speed things up.
Let’s pop out the microSD card and flash it with Ubuntu Desktop. Impressive – I hadn’t realized it was a 128 GB card. I figured it was 64. Hmm… what was the program called. Ez Flash? Hmm… Etcher! Hmm… I’m noticing that the Ubuntu server was only 1.1GB. That desktop software is enormous. In addition, I’m getting warnings about the large disk size.



Operating System Installed – Check!
Let’s see what’s involved configuring it on start-up.
- Language – English [default]
- Keyboard Layout – English (US): English (US) [default]
- Wireless – connected
- Timezone – New York [default]
- Who are you? Batman!
Operating System Configured – Check!
System Update – Check!
Let’s update via the terminal
sudo apt update sudo apt upgrade
Setup multicast DNS (kiosk.local)
sudo apt install avahi-daemon -y sudo nano /etc/hostname # should be one line with the name of your server "kiosk" sudo nano /etc/hosts # if present, replace 127.0.0.1 ubuntu with 127.0.0.1 kiosk # if 127.0.0.1 localhost, add another line 127.0.0.1 kiosk sudo systemctl restart avahi-daemon sudo systemctl restart NetworkManager
Now let’s get SSH up and running.
sudo apt install openssh-server -y sudo systemctl enable ssh sudo systemctl start ssh sudo systemctl status ssh
Now go to another computer
ssh lewis@kiosk.local # Enter password
Install curl
sudo apt install curl
Let’s install Docker
curl -sSL https://get.docker.com | sh sudo usermod -aG docker $USER logout # ssh back into the pi ssh kiosk.local groups # we see "docker" as one of the groups docker run hello-world
Let’s setup a docker container for LAMP (Linux, Apache, MariaDB, PHP) Let’s also setup phpMyAdmin to manage the database.
mkdir ~/lamp cd ~/lamp sudo nano compose.yaml # See compose.yaml below... docker compose up -d
~/lamp/compose.yaml
services:
php:
image: tobi312/php:8.3-apache-slim
container_name: php
restart: unless-stopped
ports:
- 80:80
- 443:443
volumes:
- ./html:/var/www/html:rw
- ./php/php.ini:/usr/local/etc/php/php.ini
environment:
TZ: "America/New_York"
command: ["php", "-S", "0.0.0.0:80", "-t", "/var/www/html"]
mariadb:
image: mariadb:latest
container_name: mariadb
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: '{{root password}}'
MYSQL_USER: '{{ user name }}'
MYSQL_PASSWORD: '{{ user password }}'
MYSQL_DATABASE: '{{ user db }}'
volumes:
- ./mysqldata:/var/lib/mysql
ports:
- 3306:3306
phpmyadmin:
image: phpmyadmin:latest
container_name: phpmyadmin
restart: unless-stopped
ports:
- 8080:80
environment:
- PMA_ARBITRARY=1
- PMA_HOST=mariadb
depends_on:
- mariadb
volumes:
- ./php/php.ini:/usr/local/etc/php/php.ini
Let’s log into phpMyAdmin. Use the username/password setup in the compose.yaml files environment settings for mariadb. The server will be “mariadb”, unless you specified a different container name.
http://kiosk.local:8080/index.php?route=/
Once you login, you should see that your database already exists.
Now we’ve got a LAMP server setup via docker. If we try to hit the page http://kiosk.local/ we will get an error as nothing is setup there just yet, and the server isn’t setup to allow directory listings. Let’s go and create a simple page.
sudo nano html/index.php
<?php
error_reporting(E_ALL);
ini_set('display_error', 1);
echo "<p>Hello World!</p>";
$conn = new mysqli(
"mariadb",
"{{db username}}",
"{{db password}",
"{{db name}}"
);
if ($conn->connect_error) {
die("DB Error: " . $conn->connect_error);
}
$version = $conn->server_info;
echo "DB Server Version: $version";
$conn->close();
We are having trouble with mysqli not being configured. Let’s create a php image with mysqli installed.
mkdir php-image cd php-image sudo nano dockerfile
FROM php:8.3-rc-apache RUN apt-get update RUN apt-get install --yes --force-yes cron g++ gettext libicu-dev openssl libc-client-dev libkrb5-dev libxml2-dev libfreetype6-dev libgd-dev libmcrypt-dev bzip2 libbz2-dev libtidy-dev libcurl4-op> RUN a2enmod rewrite RUN docker-php-ext-install mysqli RUN docker-php-ext-enable mysqli RUN docker-php-ext-configure gd --with-freetype=/usr --with-jpeg=/usr RUN docker-php-ext-install gd COPY ./ /var/www/html/ EXPOSE 80 443 CMD ["apache2ctl", "-D", "FOREGROUND"]
docker build -t php-image . cd .. sudo nano compose.yaml
services:
php:
build: ./php-image
container_name: php
restart: unless-stopped
ports:
- 80:80
- 443:443
volumes:
- ./html:/var/www/html:rw
- ./php/php.ini:/usr/local/etc/php/php.ini
environment:
TZ: "America/New_York"
command: ["php", "-S", "0.0.0.0:80", "-t", "/var/www/html"]
mariadb:
image: mariadb:latest
container_name: mariadb
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: '{{root password}}'
MYSQL_USER: '{{ user name }}'
MYSQL_PASSWORD: '{{ user password }}'
MYSQL_DATABASE: '{{ user db }}'
volumes:
- ./mysqldata:/var/lib/mysql
ports:
- 3306:3306
phpmyadmin:
image: phpmyadmin:latest
container_name: phpmyadmin
restart: unless-stopped
ports:
- 8080:80
environment:
- PMA_ARBITRARY=1
- PMA_HOST=mariadb
depends_on:
- mariadb
volumes:
- ./php/php.ini:/usr/local/etc/php/php.ini
Bingo! PHP is able to connect to the database.
So… we have our operating system installed and configured, multicast DNS, SSH, Docker, Apache, MariaDB, and PHP. This is a full fledged web server at the moment with a relational database. The next step is to create a simple web application to add/remove images, urls, and videos.
A project name… Sign Pi, Pi Signage, Screen Flow Pi, Display Hub, Pi Presenter, Pi Cast, Pi Vision, Info Pi, Pixel Pi, Bright Pi, View Edge Pi, Castly Pi, Sign Bot, Pi Media Hub, Visionary Pi
Let’s go with Visionary Pi. We can refer to it as Visionary Ď€ or VĎ€. Let’s try some image generation.
Present a raspberry pie as if it had the name “Visionary Pi”. It could be a pie with sunglasses. The pie is able to display digital signage on televisions in kiosk mode. For the name, you can use the mathematical pi symbol as “Visionary Ď€”



Pretty cool. Let’s see if we can narrow it down to just creating a logo.
Create a logo for software called “Visionary Ď€”. The software runs on a raspberry pie to display digital signage on televisions in kiosk mode. The logo should be on a solid white or solid black background. Ideally it should be a character of some kind that has characteristics to convey that it is associated with a raspberry pi and televisions. It also needs to express “Vision”, such as wearing sun glasses or something. It may have a television in the picture as well. The Logo needs to be something simple, like a cartoon drawing.



Hmm… I like what MS Designer Image Creator did with the TV. I’m all out of image generation for ChatGPT. Let’s hone in on Microsoft Designer.
Create a logo for software called “Visionary Ď€”. The software runs on a raspberry pie to display digital signage on televisions in kiosk mode. The logo should be on a solid black background. Ideally it should be a cartoon character that is an HDTV, but has hands and legs. The display on the television is the face of the character as a giant raspberry wearing sunglasses. The Logo needs to be something simple, like a cartoon drawing.

I think we have a winner.
Just for the fun of it, I had a LLM generate a README.md file in the project. It started off pretty well.
I’ve setup a repo https://github.com/CodeJamboree/visionary-pi
- Added instructions to setup the Pi as LAMP up to this point
- Setup basic theme / layout and website with React, Redux, RTK Query
So far, this is the general interface:

Before we move further, let’s see if we can get the Pi to boot up and load the page at http://kiosk.local
The first part is building the site and copying the files over to the Pi. I don’t have Samba or FTP installed, and I don’t want to manually copy files to a memory card, or download them from a 3rd party site every time I make a change. It looks like there is a scp command that lets me copy files over SSH.
visionary-pi git:(production) âś— scp -r dist/ui/ lewis@kiosk.local:~/lamp/html lewis@kiosk.local's password: scp: stat remote: No such file or directory scp: failed to upload directory dist/ui/ to ~/lamp/html
Well that didn’t work out. The directory exists… why is this not working? Do I need sudo? No. That was pointless. Hmm… let’s try rsync
visionary-pi git:(production) âś— rsync -avz dist/ui/ lewis@kiosk.local:~/lamp/html lewis@kiosk.local's password: building file list ... done rsync: [generator] failed to set times on "/home/lewis/lamp/html/.": Operation not permitted (1) rsync: [generator] recv_generator: mkdir "/home/lewis/lamp/html/assets" failed: Permission denied (13) *** Skipping any contents from this failed directory *** rsync: [generator] recv_generator: mkdir "/home/lewis/lamp/html/logo" failed: Permission denied (13) *** Skipping any contents from this failed directory *** ./ favicon.ico index.html assets/ logo/ rsync: [receiver] mkstemp "/home/lewis/lamp/html/.favicon.ico.wWM4SS" failed: Permission denied (13) rsync: [receiver] mkstemp "/home/lewis/lamp/html/.index.html.Qs6IQ8" failed: Permission denied (13) sent 2287 bytes received 82 bytes 249.37 bytes/sec total size is 1348567 speedup is 569.26 rsync error: some files could not be transferred (code 23) at /AppleInternal/Library/BuildRoots/4ff29661-3588-11ef-9513-e2437461156c/Library/Caches/com.apple.xbs/Sources/rsync/rsync/main.c(996) [sender=2.6.9]
Hmm… lots of details about nothing. Looking at the Pi, nothing transferred.
cd ~/lamp ls -l drwxr-xr-x 2 root root 4096 Oct 25 12:00 html
It looks like my account doesn’t own the html folder. I’m assuming this is because docker created the folder when it loaded the image.
sudo chmod 755 html
The scp and rsync commands still fail. Do I need to enable file transfer over SSH? I’m looking at /etc/ssh/sshd_config and notice it mentions sftp.
Hmm…
scp -r dist/ui/ lewis@kiosk.local:/home/lewis/foo # works scp -r dist/ui/ lewis@kiosk.local:/home/lewis/lamp/foo # works scp -r dist/ui/ lewis@kiosk.local:/home/lewis/lamp/html/foo # No such file or directory
So the issue is specific to the html folder. Lets do docker compose down and try to copy. Maybe it’s got some kind of lock on the directory. Nope. Let’s change the owner to my own account.
sudo chown lewis ~/lamp/html
Hey, it worked! Let’s bring the docker containers back up and test the site. It almost worked. It keeps creating a folder called “ui”, instead of copying directly to the folder. I tried removing the trailing slash, but it’s still doing the same thing. rsync is doing the same thing.
Ah hah! rsync needs the trailing slash to copy the contents of “ui” into the contents of “html”. And scp needs an asterisk.
rsync -avz dist/ui/ lewis@kiosk.local:~/lamp/html scp -r dist/ui/* lewis@kiosk.local:~/lamp/html
And now everything is showing up on the kiosk! Now, let’s see if we can setup the Raspberry Pi to open the web page once it starts up.
sudo apt install chromium-browser -y nano ~/kiosk.sh # See script below chmod +x ~/kiosk.sh
#!/bin/bash sleep 5 chromium-browser --noerrdialogs --disable-infobars --kiosk "http://127.0.0.1/"
Now we can run this script via the SSH terminal, but it’s going to complain:
[130857:130857:1025/164352.209059:ERROR:ozone_platform_x11.cc(244)] Missing X server or $DISPLAY
[130857:130857:1025/164352.209168:ERROR:env.cc(258)] The platform failed to initialize. Exiting.
That makes sense. We can’t display a web browser in a text-based terminal. We need to log into the Ubuntu desktop. Once you log into the desktop, go to the terminal (yes, it’s fine now, it’s within x-windows), and then run ./kiosk.sh. The Chromium web browser should start up and show our web page. To get out, just hold down [ALT]+[F4]
We did get some errors loading.
GTK+ 2.x symbols detected. Using GTK+ 2.x and GTK+ 3 is the same process is not supported.
Failed to load module canberra-gtk-module
Failed to call method: org.freedesktop.ScreenSaveer.GetActive: object_path= /org/freedesktop/ScreenSaver: org.freedesktop.Dus.Error.NotSupport: This method is nto part of the idle inhibition specification: https://specifications.freedesktop.org/idle-inhibit-spec/latest/
gl_suerface_presentation_helper.cc(260) GetVSyncParametersIfAvailable() failed for N times!
Let’s see if we need to worry about any of this.
sudo apt-get install libcanberra-gtk-module
Odd.. the browser loaded in normal windowed mode. libcanberra-gtk-module still failed to load, but the gl_surface_prsentation_helper only failed once. It seems that after the first launch, relaunching the browser doesn’t go into full screen mode. Let’s much with the kiosk script.
#!/bin/bash sleep 5 chromium-browser --noerrdialogs --disable-infobars --kiosk "http://127.0.0.1/"
Well, that expanded it to the full screen, but I still see the title bar of the browser, and the ubutu menu on the left. Let’s find every related setting and see if something sticks.
#!/bin/bash sleep 5 chromium-browser "http://127.0.0.1/" \ --kiosk \ --window-position=0,0 \ --window-size=1920,1080 \ --start-fullscreen \ --start-maximized \ --noerrdialogs \ --disable-translate \ --no-first-run \ --fast \ --fast-start \ --disable-infobars \ --disable-features=TranslateUI \ --disk-cache-dir=/dev/null \ --overscroll-history-navigation=0 \ --disable-pinch \ --check-for-update-interval=31536000
We’ve still got this problem where it works sometimes.
Let’s bypass this for now. We know how to launch the browser in kiosk mode. Let’s start it up when logging in.
mkdir -p ~/.config/autostart nano ~/.config/autostart/kiosk.desktop
[Desktop Entry] Type=Application Name=Kiosk Mode Exec=/home/lewis/kiosk.sh X-GNOME-Autostart-enabled=true
Reboot and … it works!
Let’s stop turning off the power when system is not in use
- Ubuntu Menu -> Settings ->
- Power -> Power Saving -> Screen Blank -> Never
- Ubuntu Desktop -> Dock -> Auto-hide the Dock
We are now at the part where we can develop our site. First part – upload and manage images, urls, videos, etc.
It seems that my .htaccess files are not being processed.
I’ve been running in circles for some time now. I need to write a header, so that the browser doesn’t complain about CORS headers. My dev site is not on the raspberry pi, so fetch requests go across the network. Google chrome creates an OPTIONS request, and doesn’t get a response letting it know what the web site accepts as valid hosts. Normally, I just do blanket coverage with a header via .htaccess
Header set Access-Control-Allow-Origin * Header add Access-Control-Allow-Headers * Header add Access-Control-Allow-Methods "GET, POST, OPTIONS"
This seems simple enough. Just about every web hosting provider has this enabled by default. Unfortunately, the docker image for php:8.3-apache does not. The simple fix would be to enable it via RUN a2enmod headers in my own dockerfile image – but that doesn’t enable it. However, if I log into the image after it runs docker exec -it hhhh sh and run a2enmod headers and apache2 restart, then – it gets enabled. I looked at restarting apache2 in the image itself, but that’s not how that works. Each time docker spins up a container, it’s starting up a service that wasn’t started to begin with.
Along the way, I wrote a little script to list all apache modules that have been enabled.
<?php
if (function_exists('apache_get_modules')) {
$modules = apache_get_modules();
foreach ($modules as $module) {
echo "<li>$module</li>";
}
} else {
echo 'missing apache_get_modules';
}
I’ve also been mucking with /etc/apache2/apache2.conf, setting the html directory to allow overrides.
<Directory /var/www/html>
AllowOverride All
</Directory>
The docker image needs to work out of the box once it’s setup. Shelling into the container just to enable a module defeats the purpose of setting the images up. Maybe it was enabled, but the server just needs to reset? No – if I shell into a new image and just run service apache2 restart, the module still doesn’t appear as enabled once it starts. It seems like something is off when I try to run a2enmod headers in the dockerfile. You can’t run sudo, because you are already the root user. Just for added measure, I keep building with the –no-cache option.
docker build --no-cache -t php-image .
It feels like a2enmod had a problem when used to build a dockerfile. Let’s try with progress.
docker build --no-cache --progress=plain -t php-image .
#7 [ 3/11] RUN a2enmod rewrite headers authz_core access_compat
#7 0.394 Enabling module rewrite.
#7 0.401 Enabling module headers.
#7 0.408 Module authz_core already enabled
#7 0.408 Considering dependency authn_core for access_compat:
#7 0.408 Module authn_core already enabled
#7 0.408 Module access_compat already enabled
#7 0.408 To activate the new configuration, you need to run:
#7 0.408 Â service apache2 restart
#7 DONE 0.7s
It’s showing that it’s being enabled – not that it’s already enabled. It’s also recommending to restart the service. Restarting the service during the build results in a timeout after 20 seconds. Restarting the container once it is running doesn’t result with the module being enabled, unless I run the same command again when shelled into the running container. I’ve also tried creating a symbolic link to the modules-available directory, and ran into trouble saying the file already existed.
Am I running into some kind of race condition? Is my compose.yaml not using the latest image? Is docker compose caching images?
docker-compose up --build --no-cache -d
Well shucks, I don’t have docker-compose (with the dash), and “docker compose” (no dash) doesn’t recognize the –no-cache flag. Well hey, this seems to be doing something…
docker compose build --no-cache
And now mod_headers are listed as an apache module. And my php is service the CORS headers!
Access to fetch at ‘http://kiosk.local/api/media/files/upload’ from origin ‘http://localhost:4201’ has been blocked by CORS policy: Response to preflight request doesn’t pass access control check: It does not have HTTP ok status.
Hmm… something else may be going on now. Let’s change all of our 500 status codes to 200. Maybe we can get an good response with an actual error message.
Ugh… silly mistakes. I changed some logic to exit early, and forgot to negate the condition.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Show::status(HTTP_STATUS_METHOD_NOT_ALLOWED);
return;
}
I’m still getting the 405 status code. I think it’s because it is “OPTIONS”.
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'OPTIONS') {
return;
}
if ($method !== 'POST') {
Show::status(HTTP_STATUS_METHOD_NOT_ALLOWED);
return;
}
Huzzah! File not specified. Progress!
I think the issue is that I posted the formData as JSON, and that specific object doesn’t serialize as anything. I could add something to see if I can serialize it as base64 or something. Let’s try just posting the form data in the raw.
const mediaApi = apiSlice.injectEndpoints({
endpoints: (build) => ({
uploadFile: build.mutation<void, File>({
query: (file) => {
const formData = new FormData();
formData.append('file', file);
return ({
url: `/media/files/upload`,
method: 'POST',
body: formData
})
},
})
})
});
File not specified…

Well, I can see it there. It says name=”file”. Oh here is something, the content type is application/json. Normally I would always want JSON, but uploads are special.
The request headers are now sending multipart/form-data. However, I’ve got the same error. It’s time to check out our php.ini file to determine if we have limitations on the maximum file size to upload, or the maximum size of POST data.
post_max_size = 8M
file_uploads = On
upload_max_filesize = 2M
max_file_uploads = 20
memory_limit = 128M
;max_input_vars = 1000
;max_multipart_body_parts = 1500
;session.upload_progress.enabled = On
;session.upload_progress.cleanup = On
;session.upload_progress.prefix = “upload_progress_”
;session.upload_progress.name = “PHP_SESSION_UPLOAD_PROGRESS”
;session.upload_progress.freq = “1%”
;session.upload_progress.min_freq = “1”
So… not much. The files I’ve been trying to upload are less than 1MB. I decided to set the post_max_size and upload_max_filesize to 0, since this is going to allow video to be uploaded.
Let’s print out $_FILES
echo '<pre>'; print_r($_POST); print_r($_FILES); echo '</pre>';
<pre>Array ( ) Array ( ) </pre>
echo '<pre>'; var_dump($_POST); var_dump($_FILES); echo '</pre>';
<pre>array(0) {
}
array(0) {
}
</pre>
What… is… going… on? Let’s display some errors.
error_reporting(E_ALL);
ini_set('display_errors', 'On');
Warning: mkdir(): Permission denied in /var/www/html/api/media/files/upload.php on line 19
Well, that’s just a warning, and it isn’t coming into play just yet. Going back to the dockerfile, I’m running chmod 775 /var/www/html. I don’t have much hope… yea, still denied. Let’s shell into it. Same issue. Am I using the write number? Should it be 6? Forbidden… well, I don’t see an uploads folder under html. Hmm… 777 did the trick. I now see an uploads folder.
The warning is gone, but I still have a problem where $_FILES and $_POST are not populated.
Let’s skip the api request for now. Let’s try a normal html form
<form action="upload.php" method="POST" enctype="multipart/form-data"> <input type="file" name="file" required /> <button type="submit">Upload</button> </form>
It uploaded…. what’s different? The content type specified the boundary…

I don’t think I have access to that in the api slice when working with FormData. Can I just make something up? No – It just ends up not sending the file at all. Let’s do this… if I see a content-type header that’s multipart/form-data, I’ll just delete it. Maybe the browser can work its magic.
const baseQuery = fetchBaseQuery({
baseUrl,
prepareHeaders(headers) {
if (!headers.has('Content-Type'))
headers.set('Content-Type', 'application/json');
else if (headers.get('Content-Type') === 'multipart/form-data') {
headers.delete('Content-Type');
}
return headers;
}
});
Uh… it worked? The browser added the multipart/form-data itself, along with the boundary. And I’m noticing a few files in the uploads folder.

So we can now upload files over the apiSlice. The next part is scanning that directory for files that aren’t in the database, and start creating records. I want to let people manage videos and images that were uploaded. On top of that, I need to detect the duration of videos, and duration of animated GIF’s.
It’s getting late, and this post has been growing for a couple days. I’ve got to get up early as well. Lots to do tomorrow.
