I installed Snipe-IT on Ubuntu — storage stayed unwritable until www-data owned it

I installed Snipe-IT on Ubuntu — storage stayed unwritable until www-data owned it

I wanted hardware and license seats off a spreadsheet. Snipe-IT is Laravel. On Ubuntu 24.04, composer install finished, then the UI 500'd until storage and bootstrap/cache belonged to www-data.

· Updated · 9 min read #self-hosted #laravel #snipe-IT #open-source #license-management #deployment #docker #vps

 Snipe-IT installation overview

Caption: Snipe-IT open-source IT asset management installation overview.

Introduction

I wanted one place for laptops, monitors, and software seats — who has what, when we bought it, when it depreciates. Spreadsheets rot. Snipe-IT is a Laravel app for that: barcodes, check-in/check-out, LDAP if you need it, reports. I put it on Ubuntu with Nginx and MariaDB. Docker is the shorter path if I only need a lab.

On a fresh Ubuntu 22.04/24.04 box this install is famous for a blank 500 after Composer because storage/ is still owned by root, and for mail that never leaves because nobody runs queue:work.

 Snipe IT dashboard

Caption: Snipe-IT Dashboard showing key metrics and recent activity (example from official demo).

Where it broke

1. Permission denied / 500 after Composer

On a fresh Ubuntu box this is the failure this install is famous for. You cloned as root, composer install ran as root, FPM runs as www-data.

The stream or file "storage/logs/laravel.log" could not be opened in append mode:
failed to open stream: Permission denied

Fix from this post:

sudo chown -R www-data:www-data /var/www/snipe-it
sudo chmod -R 775 storage bootstrap/cache

2. Queue / mail never fires

Checkout emails and notifications are Laravel jobs if you use the queue. This post already documents php artisan queue:work and a scheduler cron. If neither is running, the UI looks fine and the mailbox is empty.

cd /var/www/snipe-it
php artisan queue:work

And crontab:

* * * * * cd /var/www/snipe-it && php artisan schedule:run >> /dev/null 2>&1

Test SMTP in .env after a worker exists, not before.

Prerequisites

Hardware (small/medium team):

  • CPU: 2+ cores
  • RAM: 4 GB minimum (8 GB+ with many users/assets)
  • Storage: 10 GB+ (images and DB grow)
  • A browser you actually use

Software:

  • Ubuntu 22.04 or 24.04 LTS
  • Nginx (or Apache)
  • MySQL 8.0+ or MariaDB 10.6+
  • PHP 8.2+ with the extensions below
  • Composer, Git
  • Redis optional for cache/queues

Access: sudo, DNS for assets.example.com, Let’s Encrypt.

PHP extensions: bcmath, ctype, curl, dom, fileinfo, gd, iconv, intl, json, mbstring, mysqlnd, openssl, pdo, pdo_mysql, tokenizer, xml, zip.

Installation Guide

Manual when I want the files on disk. Docker when I want it up tonight.

Method 1: Manual on Ubuntu

  1. Packages:
sudo apt update && sudo apt upgrade -y
sudo apt install nginx mariadb-server php8.2-fpm php8.2-cli php8.2-common php8.2-mysql php8.2-gd php8.2-curl php8.2-mbstring php8.2-xml php8.2-zip php8.2-bcmath php8.2-intl php8.2-redis git unzip curl -y
  1. Composer:
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
  1. Database:
sudo mysql -u root -p
CREATE DATABASE snipeit CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'snipeituser'@'localhost' IDENTIFIED BY 'StrongPasswordHere123!';
GRANT ALL PRIVILEGES ON snipeit.* TO 'snipeituser'@'localhost';
FLUSH PRIVILEGES;
EXIT;
  1. Source:
cd /var/www
sudo git clone https://github.com/grokability/snipe-it.git
cd snipe-it
sudo git checkout v8.6.3  # Use latest stable tag
  1. PHP dependencies:
sudo composer install --no-dev --optimize-autoloader

If Composer OOMs on a tiny VPS, COMPOSER_MEMORY_LIMIT=-1 is the usual Laravel fix. Match PHP CLI to 8.2.

  1. Permissions (do this before you hit the URL):
sudo chown -R www-data:www-data /var/www/snipe-it
sudo chmod -R 755 storage bootstrap/cache

I would use 775 on storage and bootstrap/cache if FPM still cannot write logs.

Method 2: Docker

version: '3.8'
services:
  db:
    image: mariadb:10.11
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: snipeit
      MYSQL_USER: snipeit
      MYSQL_PASSWORD: strongpass
    volumes:
      - db-data:/var/lib/mysql
    restart: unless-stopped

  snipeit:
    image: snipe/snipe-it:latest
    ports:
      - "8080:80"
    environment:
      APP_URL: http://your-domain.com
      APP_KEY: base64:$(openssl rand -base64 32)
      DB_HOST: db
      DB_DATABASE: snipeit
      DB_USERNAME: snipeit
      DB_PASSWORD: strongpass
    volumes:
      - snipeit-data:/var/lib/snipeit
    depends_on:
      - db
    restart: unless-stopped

volumes:
  db-data:
  snipeit-data:
docker compose up -d

If the container loops, docker logs and volume permissions. Generate a real APP_KEY rather than leaving the openssl substitution unexpanded in YAML if your Compose version does not run it.

Configuration

cp .env.example .env
nano .env
APP_NAME="Snipe-IT"
APP_ENV=production
APP_KEY=base64:your-generated-key-here
APP_DEBUG=false
APP_URL=https://assets.example.com

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=snipeit
DB_USERNAME=snipeituser
DB_PASSWORD=StrongPasswordHere123!

MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=your@email.com
MAIL_PASSWORD=yourpassword
MAIL_FROM_ADDRESS=assets@example.com

REDIS_HOST=127.0.0.1

Laravel setup:

php artisan key:generate
php artisan migrate --force
php artisan db:seed --class=InitialSeeder
php artisan storage:link
php artisan config:cache
php artisan route:cache
php artisan view:cache

Some releases prefer the web installer for the first admin instead of InitialSeeder. If seed class is missing, use the browser wizard and skip that one artisan line.

Nginx (/etc/nginx/sites-available/snipeit):

server {
    listen 80;
    server_name assets.example.com;
    root /var/www/snipe-it/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }
}
sudo ln -s /etc/nginx/sites-available/snipeit /etc/nginx/sites-enabled/ && sudo nginx -t && sudo systemctl restart nginx

SSL:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d assets.example.com

 Snipe IT dashboard

Caption: Snipe-IT Assets list view with search, filters, and actions.

More about packages

Snipe-IT is Laravel. The GitHub README will get you clone + Composer. Ops still is:

  • Laravel — .env, APP_KEY, artisan migrate, storage:link, FPM user vs file owner. That permission 500 is this stack.
  • Queues / scheduler — mail and some notifications. queue:work plus schedule:run cron, already in this post. I am not adding Horizon unless you confirmed it in their composer.json.
  • Redis (optional) — cache/sessions if you set it. Not required for a first checkout.
  • LDAP — optional in settings. Firewall and bind DN, not a Composer package surprise.

No Spatie list here. I did not invent extra vendor names.

Usage

Open https://assets.example.com. Finish the web installer if you did not seed an admin. Then:

  • Assets with tags and photos; checkout to a user
  • Licenses and seats
  • Reports and audit log
  • Users/groups; LDAP import if that is your directory
  • API for scripts

CLI that matters in production:

php artisan queue:work
* * * * * cd /var/www/snipe-it && php artisan schedule:run >> /dev/null 2>&1

 Snipe IT dashboard

Caption: Assets by Status pie chart and category breakdown.

MVC, Blade, Laravel relationships. I do not need a slide about that to check out a laptop.

A feature I would not have found from the homepage

Check-out email is a queue, not a button. The UI will say the laptop is assigned. The user never gets mail if queue:work is not running and SMTP is wrong. I would send one checkout to myself before I import 400 rows.

Barcodes and labels assume APP_URL and storage:link. Asset photos and printable tags 404 the same way Bagisto product images do: wrong APP_URL, no link, or mixed content. Fix those before you buy a label printer.

CSV import is how you leave the spreadsheet. The marketing site talks about dashboards. The operator path is: custom fields that match your columns, then import, then a first audit. I would not LDAP-sync on day one. Get local users and one site working, then bind to AD when the firewall and bind DN are boring.

Backups are the database plus storage/. Depreciation reports are worthless if the MySQL dump is a week old and uploaded photos live only on the VPS. I would dump snipeit nightly and copy /var/www/snipe-it/storage off-box. git pull to a new tag, then composer install --no-dev and php artisan migrate — not main in production without reading the release notes.

LDAP. Settings in the UI, not a secret Composer package. If sync fails, it is bind credentials, TLS, or the firewall — this post already lists that. I would not open 389/636 to the world.

Day two on the VPS

Asset data is worse to lose than a blog post. After the first checkout works I would:

  • Put queue:work under Supervisor so a reboot does not silently kill mail. The crontab schedule:run line does not replace a worker.
  • Dump the snipeit database nightly and copy storage/ (photos, backups Snipe-IT writes locally).
  • Force HTTPS and set APP_URL to that name, then php artisan config:cache.
  • Change the first admin password and turn off APP_DEBUG.
  • Import a small CSV before the whole fleet. Custom fields that do not match columns create junk assets that take longer to clean than they took to import.
  • Keep Redis optional. File cache is enough until the UI is slow.

php8.2-xml and gd are in the apt line for a reason: labels and image uploads. If I used a different PHP minor, I would install the matching -xml and -gd packages, not hope php -m looks like 8.2.

Troubleshooting

  • Permission denied: chown -R www-data:www-data storage bootstrap/cache and chmod -R 775 storage.
  • Composer: composer install --no-dev in the project root; PHP version must match.
  • Database: .env then php artisan config:clear.
  • Blank page / 500: storage/logs/laravel.log. APP_DEBUG=true only while you look.
  • Queue/mail: worker or Supervisor; test SMTP after that.
  • Docker: docker logs and volume permissions.
  • LDAP: firewall, bind credentials, test in settings.

OPCache, Redis, backups: DB dump plus /storage.

Conclusion

Snipe-IT is running on my Ubuntu VPS: Git checkout of a stable tag, Composer --no-dev, Nginx to public/, migrations, storage:link. Next I would put a Supervisor worker on queue:work, Let’s Encrypt on assets.example.com, and a CSV import of the current laptop list. Then Fail2Ban and git pull + migrate on a schedule.

References

Did you hit the same wall?

I got stuck on failed to open stream: Permission denied writing storage/logs/laravel.log until www-data owned storage and bootstrap/cache. Did you hit the same thing, or a different one — Composer memory, storage permissions, PHP extensions, a queue worker that never started? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.