Self-hosting Pixelfed on OpenBSD

       1894 words, 9 minutes

In case you don’t already know, Pixelfed is a media sharing oriented solution that federates with Fediverse using the ActivityPub . And I like it also because it is based on PHP. This makes it quite simple to be hosted on OpenBSD. And here’s how I do this.

The official Run your own Pixelfed website documentation is not that much Linux-centric and really good. I’ll reuse its structure hoping this produces an homage and not a plagiarism ;-)

Preparing your machine

I’m using a server that runs OpenBSD 7.3-STABLE. Depending on your reading timeline, you may need to adapt the following directions.

Creating a dedicated app-user

The user has its own resource class and home directory. The PHP scripts will be chrooted and require their own sets of system files and sockets.

# cat > /etc/login.conf.d/pixelfed

# useradd -c "PixelFed user" -d /home/pixelfed \
    -m -g =uid -L pixelfed -s /sbin/nologin    \
    -r 2000..3000 _pixelfed

# mkdir -p ~_pixelfed/{bin,cache,dev,etc/ssl,logs,run,tmp}
# cp -p /etc/hosts /etc/resolv.conf ~_pixelfed/etc/
# cp -p /etc/ssl/cert.pem ~_pixelfed/etc/ssl/
# chown -R _pixelfed:_pixelfed ~_pixelfed/{cache,tmp}

# rcctl set syslogd flags -a ~_pixelfed/dev/log
# rcctl restart syslogd

Install and configure dependencies

Because the database and the cache are used by several other applications, and because I use rdomain(4), I don’t connect using sockets but using IP. I also have to upgrade some max values. You may not have to do it this way.

Configuring Redis socket

The memory cache is mutualized and using rdomain:

# pkg_add redis

# vi /etc/redis/redis.conf
maxclients 1024

# rcctl enable redis
# rcctl set redis rtable 1
# rcctl start redis


I’m using MariaDB ; mostly because I know MySQL better that PostgreSQL. It is isolated using rdomain and has its own, non-default, storage location.

# pkg_add mariadb-server

# vi /etc/my.cnf

# /usr/local/bin/mariadb-install-db
# rcctl enable mysqld
# rcctl set mysqld rtable 1
# rcctl start mysqld
# mariadb-secure-installation

# mysql -u root -p
MariaDB [(none)]> CREATE DATABASE pixelfed;
MariaDB [(none)]> GRANT ALL PRIVILEGES ON pixelfed.*                         \
                  TO 'pixelfed'@'localhost' IDENTIFIED BY '<changeme>';
MariaDB [(none)]> EXIT;


Because I’m not using Apache, I run php-fpm(8):

# pkg_add composer    \
    pecl81-imagick    \
    pecl81-redis      \
    php-curl%8.1      \
    php-gd%8.1        \
    php-intl%8.1      \
    php-mysqli%8.1    \
    php-pcntl%8.1     \
    php-pdo_mysql%8.1 \
# cd /etc/php-8.1; ln -s ../php-8.1.sample/* .; cd -

I also run it on its own rdomain:

# cp -p /etc/php-8.1.ini /etc/php-8.1-pixelfed.ini
# vi /etc/php-8.1-pixelfed.ini
max_execution_time = 600
error_log = syslog
syslog.ident = php-fpm
date.timezone = Europe/Amsterdam
; bigger value for Instagram imports
post_max_size = 512M
upload_max_filesize = 512M
max_file_uploads = 1024

# cp -p /etc/php-fpm.conf /etc/php-fpm-pixelfed.conf
# vi /etc/php-fpm-pixelfed.conf
error_log = syslog
syslog.ident = php-fpm-pixelfed
user = _pixelfed
group = _pixelfed
listen = /home/pixelfed/run/php-fpm.sock
listen.owner = _pixelfed = _pixelfed
chroot = /home/pixelfed

# ln -s /etc/rc.d/php81_fpm /etc/rc.d/php81_fpm_pixelfed
# rcctl enable php81_fpm_pixelfed
# rcctl set php81_fpm_pixelfed flags \
    -c /etc/php-8.1-pixelfed.ini -y /etc/php-fpm-pixelfed.conf
# rcctl set php81_fpm_pixelfed rtable 2
# rcctl start php81_fpm_pixelfed

Generic Installation guide

Setting up Pixelfed files

Get Pixelfed sources from git and set the proper permissions:

# pkg_add git

# cd ~_pixelfed
# umask 022
# git clone -b dev src
# chown -R _pixelfed:_pixelfed src

# ln -s . home
# ln -s . pixelfed

Note that the dev branch shall become stable someday.
Also note the symbolic links magic. It is used to be able to chroot the PHP scripts eventhough Pixelfed is not aware of this.

Initialize PHP dependencies

Every dependencies can be fetched and installed using composer. Running it as the _pixelfed user enforces the use of $HOME and does not wreck the whole /usr/local.

# cd ~_pixelfed/src
# doas -u _pixelfed composer install --no-ansi \
  --no-interaction --optimize-autoloader

Configure environment variables

The configuration file is build from the example env file.

# cd ~_pixelfed/src
# doas -u _pixelfed cp .env.example .env
# vi .env

Setting up services

One time tasks

Things to run once to setup everything.

# cd ~_pixelfed/src
# doas -u _pixelfed php artisan key:generate
# doas -u _pixelfed php artisan storage:link
# doas -u _pixelfed php artisan migrate --force
# doas -u _pixelfed php artisan import:cities
# doas -u _pixelfed php artisan instance:actor
# doas -u _pixelfed php artisan passport:keys

# doas -u _pixelfed php artisan route:cache
# doas -u _pixelfed php artisan view:cache

# doas -u _pixelfed php artisan config:cache

Note that the config:cache must be run each time the .env file is modified.

Job queueing

The Laravel Horizon software is used to manage queues. Create and enable access to the dashboard. Then create an rc script that will start it at boot time:

# cd ~_pixelfed/src
# doas -u _pixelfed php artisan horizon:install
# doas -u _pixelfed php artisan horizon:publish

# vi /etc/rc.d/pixelfed

daemon="/usr/local/bin/php artisan --no-ansi horizon"

. /etc/rc.d/rc.subr


rc_cmd $1

# chmod 0755 /etc/rc.d/pixelfed
# rcctl enable pixelfed
# rcctl set pixelfed rtable 2
# rcctl start pixelfed

Scheduling periodic tasks

The task scheduler is used to run periodic commands in the background, such as media optimization, garbage collection, and other time-based tasks that should be run every once in a while.

# vi /home/pixelfed/bin/periodic_tasks


cd "$HOME/src"
php artisan --no-ansi schedule:run 2>&1 | \
        grep -v '^$' | \
        logger -ip -t pixelfed_periodic_tasks


# chown _pixelfed /home/pixelfed/bin/periodic_tasks
# chmod 0750 /home/pixelfed/bin/periodic_tasks

# crontab -u _pixelfed -e
* * * * * -s /home/pixelfed/bin/periodic_tasks

Handling web requests

I’ll be using nginx(8). Mostly because I don’t want to add to many specifics right now. Someday, I shall migrate to httpd(8).

# pkg_add nginx

# ln -s /etc/rc.d/nginx /etc/rc.d/nginx_pixelfed

# rcctl enable nginx_pixelfed
# rcctl set nginx_pixelfed flags                     \
  -c /etc/nginx/nginx_pixelfed.conf -p /home/pixelfed
# rcctl set nginx_pixelfed rtable 2

# vi /etc/nginx/nginx_pixelfed.conf
user  _pixelfed;
worker_processes  1;
error_log  /dev/null;
error_log  syslog:server=unix:/dev/log,nohostname,tag=nginx_pixelfed,severity=notice;
worker_rlimit_nofile 1024;
events {
  worker_connections  800;
http {
  include      mime.types;
  default_type application/octet-stream;
  index        index.html index.htm;
  log_format   main  '$remote_addr - $remote_user [$time_local] "$request" '
              '$status $body_bytes_sent "$http_referer" '
              '"$http_user_agent" "$http_x_forwarded_for"';
  access_log  /dev/null;
  access_log  syslog:server=unix:/dev/log,nohostname,tag=nginx_pixelfed,severity=notice main;
  keepalive_timeout  65;
  gzip  on;
  server_tokens off;
  server {
    root /home/pixelfed/src/public;
    client_body_temp_path /home/pixelfed/cache/client_body_temp;
    proxy_temp_path       /home/pixelfed/cache/proxy_temp;
    fastcgi_temp_path     /home/pixelfed/cache/proxy_temp;
    uwsgi_temp_path       /home/pixelfed/cache/uwsgi_temp;
    scgi_temp_path        /home/pixelfed/cache/scgi_temp;
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-XSS-Protection "1; mode=block";
    add_header X-Content-Type-Options "nosniff";
    index index.php;
    charset utf-8;
    # Adapt to /etc/php-8.1-pixelfed.ini post_max_size and upload_max_filesize
    client_max_body_size 512M;
    location / {
      try_files $uri $uri/ /index.php?$query_string;
    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }
    error_page 404 /index.php;
    location ~ \.php$ {
      fastcgi_split_path_info ^(.+\.php)(/.+)$;
      fastcgi_pass unix:run/php-fpm.sock;
      fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
      fastcgi_param QUERY_STRING    $query_string;
      fastcgi_param REQUEST_METHOD  $request_method;
      fastcgi_param CONTENT_TYPE    $content_type;
      fastcgi_param CONTENT_LENGTH  $content_length;
      fastcgi_param SCRIPT_NAME     $fastcgi_script_name;
      fastcgi_param REQUEST_URI     $request_uri;
      fastcgi_param DOCUMENT_URI    $document_uri;
      fastcgi_param DOCUMENT_ROOT   $document_root;
      fastcgi_param SERVER_PROTOCOL $server_protocol;
      fastcgi_param GATEWAY_INTERFACE CGI/1.1;
      fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
      fastcgi_param REMOTE_ADDR     $remote_addr;
      fastcgi_param REMOTE_PORT     $remote_port;
      fastcgi_param SERVER_ADDR     $server_addr;
      fastcgi_param SERVER_PORT     $server_port;
      fastcgi_param SERVER_NAME     $server_name;
      fastcgi_param HTTPS           $https if_not_empty;
      fastcgi_param REDIRECT_STATUS 200;
      fastcgi_param HTTPS           $https if_not_empty;
      fastcgi_param REDIRECT_STATUS 200;
      fastcgi_param HTTP_PROXY  "";
    location ~ /\.(?!well-known).* {
      deny all;

# rcctl start nginx_pixelfed

Exposing application on the Internet

Because #reasons, I’m using relayd(8) to expose Pixelfed. That’s why nginx only runs on localhost using HTTP. relayd(8) and httpd(8) take care of HTTPS.

# vi /etc/relayd.fedi.conf
prefork 3
log state changes
log connection
table <localhost>       { }
table <pixelfed>        { }
http protocol www {
  request header "Host" value "" forward to <localhost>
http protocol wwwtls {
  tls keypair
  http websockets
  tcp { nodelay, socket buffer 65536 }
  request header "Host" value "" forward to <pixelfed>
relay www {
        listen on $ip4_fedi port 80
        protocol www
        forward to <localhost> port 80
relay www4tls {
        listen on $ip4_fedi port 443 tls
        protocol wwwtls
        forward to <pixelfed> port 8887

# ln -s /etc/rc.d/relayd /etc/rc.d/relayd_fedi
# rcctl enable relayd_fedi
# rcctl set relayd_fedi flags -f /etc/relayd.fedi.conf
# rcctl set relayd_fedi rtable 2
# rcctl start relayd_fedi

From here, Pixelfed should be up and running.
Using a Web browser, go to your configured FQDN and enjoy.

Administering your website

User management / creation

I didn’t activate registration so I have to create my user(s) manually.

# cd ~_pixelfed/src
# doas -u _pixelfed php artisan user:create
Creating a new user...

 > Joel Carnat

 > joel

 > px<at>fqdn<dot>net

 > (...)

 Confirm Password:
 > (...)

 Make this user an admin? (yes/no) [no]:
 > no

 Manually verify email address? (yes/no) [no]:
 > no

 Are you sure you want to create this user? (yes/no) [no]:
 > yes

Created new user!

Now, browse to the Pixelfed UI, log in and start sharing images.

You can browse to /i/admin/diagnostics/home using an admin account to check if everything is ok.

Instagram Import

I wanted to import all my IG photos to Pixelfed. So I went to Instagram and requested my full archive, in JSON mode. A couple of minutes later, I got it.

By default, IG import is not enabled. So browse to the config section and enable Instagram Import. I have done it in my config file.

Browse to Settings / Import, click the “Import” button and follow the wizard. Depending on your archive size and server speed, it can take several minutes to proceed. So just wait :)

Not sure why, but after the import, the posts counter was not updated. But there is a simple command to solve this:

# doas -u _pixelfed php artisan fix:statuscount

Instagram imports have their visibility set to “hidden from public feeds”. I want them to be public and have not seen a setting for this. So I invoked a bit of SQL magic:

> UPDATE statuses SET scope='public',visibility='public' \
  WHERE profile_id='<MY_PROFILE_ID>' AND scope='unlisted';
Query OK, 342 rows affected (0.019 sec)
Rows matched: 342  Changed: 342  Warnings: 0

After such a backend operation, it is required to clear the Redis cache so that the photos appear as requested.

# redis-cli INFO keyspace

# redis-cli -n 2 FLUSHDB

The pictures can now be shared and added to Collections.


There are PHP Artisan commands dedicated to backup. See here for more details.

I am using those as part of my rsnapshot process. I have created a local backup script:

# cat /home/scripts/rsnapshot-pixelfed-backup
#!/usr/bin/env ksh
# Script called by remote rsnapshot
# Backup Pixelfed (in ./storage/app/Foo'l'Bazar)


cd /home/pixelfed/src

umask 0066

doas -u _pixelfed php artisan backup:run --quiet
doas -u _pixelfed php artisan backup:clean --quiet

exit 0

On the rsnapshot server, the configuration goes like this:

backup_exec ssh -i /backup/.ssh/backup backup@pixelfed \
  "/home/scripts/rsnapshot-pixelfed-backup" required/

backup backup@pixelfed:/home/pixelfed/src/storage/app/Foo\'l\'Bazar/ \

Updating Pixelfed

Updating Pixelfed is currently done by pulling the dev branch and running a few PHP commands:

# cd ~_pixelfed/src

# doas -u _pixelfed git pull origin dev
# doas -u _pixelfed composer install
# doas -u _pixelfed php artisan config:cache
# doas -u _pixelfed php artisan route:cache
# doas -u _pixelfed php artisan migrate --force

# rcctl restart pixelfed

You are now ready to share your 📷 photos in the wild!

One More Thing

I’ll be posting using . My Instagram feed has been imported. I just need to edit a few descriptions and AltTexts. Feel free to browse it, come say Hello World! and let me know about your account :)