Skip to content

Administrator Manual

This guide covers installing, configuring, and operating Snackbox on a Linux host. The recommended deployment method is Docker Compose. A bare-metal systemd deployment is also supported for hosts without Docker.

Requirements

Platform

Requirement Notes
Linux x86-64 or arm64 Only supported deployment platform
Docker ≥ 24 + Compose v2 For container-based deployments (recommended)
systemd ≥ 232 For bare-metal service deployments
Reverse proxy nginx, Caddy, or similar - do not expose Snackbox directly to the internet

Resources

The figures below are based on benchmarks of the current binary under moderate concurrent load (700 posts, 50 parallel read requests, 30 parallel write requests). The benchmark covers the full feature set including security headers, Prometheus metrics, JWT refresh tokens, social accounts, and navigation. Media storage is not included - plan disk separately based on expected upload volume.

Minimal Recommended
RAM 64 MB 128 MB
CPU 1 vCPU 2 vCPU
Disk - binary 20 MB 20 MB
Disk - database 100 MB 1 GB
Disk - media - plan per upload volume

RAM: The process idles at ~18 MB RSS and peaks around 26 MB under concurrent load. The idle floor increased slightly compared to earlier releases due to additional features (security headers, Prometheus metrics, refresh-token state, social accounts, navigation). 64 MB leaves comfortable headroom for the OS; 128 MB is appropriate for sustained high-traffic use.

Throughput (single core, loopback, 700 posts in DB):

Endpoint Concurrency Requests/s p50 p99
GET /api/v1/posts 50 ~3 600 13 ms 35 ms
PUT /api/v1/tags/:id (authenticated write) 30 ~25 000 1 ms 4 ms

These figures are measured on loopback (no network overhead) and reflect single-process performance. Expect lower numbers in production behind a reverse proxy with real network latency.

Database: SQLite starts at under 10 KB and grows with content. Average storage is roughly 500 bytes per post for medium-length content - 10 000 posts use around 5 MB. Even a large archive stays well below 1 GB. SQLite does not load the entire database into RAM; it reads 4 KB pages on demand.

Media: Media files are stored at STORAGE_PATH and are not reflected in the database size. Allocate storage based on expected upload volume and back up DATABASE_PATH and STORAGE_PATH together.

Deploy

Pre-built images are published to the GitLab container registry:

registry.gitlab.com/cozybadgerde/applications/snackbox:latest
registry.gitlab.com/cozybadgerde/applications/snackbox:<version>

A ready-to-use docker-compose.yml is provided in the repository at deployments/docker/docker-compose.yml. The quickest way to get it is:

curl -LO https://gitlab.com/cozybadgerde/applications/snackbox/-/raw/trunk/deployments/docker/docker-compose.yml
curl -LO https://gitlab.com/cozybadgerde/applications/snackbox/-/raw/trunk/configs/.env.example
cp .env.example .env
chmod 0600 .env

1. Configure the environment

Edit .env and set at minimum:

JWT_SECRET=<random-string-min-32-characters>
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=<strong-password>

See the Configuration Reference below for all available variables.

2. Start the service

docker compose up -d

The container binds the API to 127.0.0.1:8080 and the metrics endpoint to 127.0.0.1:9091 by default. Place a reverse proxy in front of it for TLS and public access.

Useful commands

# Follow logs
docker compose logs -f

# Stop
docker compose down

# Update to a new image
docker compose pull
docker compose up -d

Scheduled publishing

The publish-due command publishes posts and pages whose publish_at timestamp is in the past. The API server does not trigger it automatically - it must be invoked on a schedule.

Option A - host cron (simplest)

Add a cron entry on the host that runs docker compose exec at your desired cadence:

# /etc/cron.d/snackbox-publish  -  every minute
# Adjust the path to docker-compose.yml and the service name if needed.
* * * * * root docker compose \
  -f /opt/snackbox/docker-compose.yml \
  exec -T snackbox /usr/local/bin/snackbox publish-due

Option B - Ofelia overlay (self-contained)

Ofelia is a lightweight Go-based job scheduler for Docker. A ready-to-use Compose overlay is included in the repository at deployments/docker/docker-compose.publish-due.yml:

docker compose \
  -f deployments/docker/docker-compose.yml \
  -f deployments/docker/docker-compose.publish-due.yml \
  up -d

The overlay runs publish-due every minute by default. Adjust the cadence by changing the ofelia.job-exec.publish-due.schedule label on the snackbox service to any cron expression or Ofelia shorthand (@every 5m, @hourly, etc.), then restart the stack.

Binary (systemd)

A dedicated snackbox system user with no login shell and write access to /var/lib/snackbox is required.

1. Create the system user

sudo useradd --system --no-create-home --shell /sbin/nologin snackbox

2. Install the binary

Download the latest release binary for your architecture from the Releases page and install it:

arm64 users: replace snackbox-linux-amd64 with snackbox-linux-arm64 in the commands below.

# Verify the download against the published checksum before installing
sha256sum -c snackbox-linux-amd64.sha256
sudo install -m 0755 snackbox-linux-amd64 /usr/local/bin/snackbox

Release checksums (*.sha256) are published alongside each binary on the Releases page.

SQL migrations are embedded in the binary and applied automatically on startup - no separate migration files are needed.

3. Create the data directory

sudo mkdir -p /var/lib/snackbox/media
sudo chown -R snackbox:snackbox /var/lib/snackbox

4. Configure the environment

Create /etc/snackbox/snackbox.env with at minimum the required variables:

sudo mkdir -p /etc/snackbox
sudo tee /etc/snackbox/snackbox.env > /dev/null <<'EOF'
JWT_SECRET=<random-string-min-32-characters>
ADMIN_NAME=Admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=<strong-password>
EOF
sudo chmod 0640 /etc/snackbox/snackbox.env
sudo chown root:snackbox /etc/snackbox/snackbox.env

5. Install and start the systemd service

The service file is included in the repository at deployments/systemd/snackbox.service.

sudo cp deployments/systemd/snackbox.service /etc/systemd/system/snackbox.service
sudo systemctl daemon-reload
sudo systemctl enable --now snackbox

Verify it is running:

sudo systemctl status snackbox
curl http://localhost:8080/health

6. Install the scheduled publishing timer (optional)

The publish-due command publishes all posts and pages whose publish_at timestamp is past due. Install the accompanying systemd timer to run it automatically on a schedule:

sudo cp deployments/systemd/snackbox-publish-due.service /etc/systemd/system/
sudo cp deployments/systemd/snackbox-publish-due.timer   /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now snackbox-publish-due.timer

The timer fires every minute by default. To change the cadence, edit /etc/systemd/system/snackbox-publish-due.timer and update the OnCalendar= line, then reload:

sudo systemctl daemon-reload
sudo systemctl restart snackbox-publish-due.timer

Common schedules:

OnCalendar= value Cadence
minutely every minute (default)
*:0/5 every 5 minutes
hourly every hour

Verify the timer is active:

systemctl status snackbox-publish-due.timer
systemctl list-timers snackbox-publish-due.timer

Kubernetes

Snackbox 1.x is a single-process, local-disk application and does not support Kubernetes or horizontal scaling. It requires a single writable filesystem for the SQLite database and media storage, which is incompatible with multi-replica Kubernetes deployments.

Kubernetes support (with MariaDB and S3 media backends for shared state) is planned for the 2.0.0 line. If you are evaluating Snackbox for a Kubernetes environment, follow the tracking issue for 2.0.0 multi-backend support.

Configuration Reference

All configuration is done via environment variables. The server reads an optional .env file at startup; variables already set in the environment take precedence.

Required in production

Variable Default Description
JWT_SECRET (auto-generated) Secret key for signing JWT tokens. Must be at least 32 characters. When unset, Snackbox generates a cryptographically random secret at startup and logs the value with a WARN message. The generated secret is ephemeral - all sessions are invalidated on every restart. Pin this value in .env for any deployment where persistent sessions matter.

Bootstrap (first run only)

These variables are only used when no admin user exists in the database. They are ignored on subsequent startups.

Variable Default Description
ADMIN_NAME - Display name of the initial admin user
ADMIN_EMAIL - Email address of the initial admin user
ADMIN_PASSWORD - Password of the initial admin user
SEED_CONTENT false When true, loads the Birkenweg Bakery demo dataset (posts, pages, tags, navigation items, social accounts, and media) on first startup. Skipped automatically if the seed post already exists. Disabled by default so fresh production deployments do not receive demo content without an explicit opt-in. See the Seed Fixtures section of the Developer Manual for the full list of what gets created.

Server

Variable Default Description
LISTEN_ADDR :8080 Address and port the API server listens on
METRICS_ADDR :9091 Address and port of the Prometheus metrics endpoint. The default :9091 binds to all interfaces - Snackbox logs a startup warning when this is the case. Set to 127.0.0.1:9091 to restrict to loopback. Keep this port off the public internet.
READ_HEADER_TIMEOUT 10s Maximum duration to read request headers. Protects against Slowloris-style attacks that send headers one byte at a time to hold connections open indefinitely.
READ_TIMEOUT 60s Maximum duration to read the full request
WRITE_TIMEOUT 60s Maximum duration to write the full response
IDLE_TIMEOUT 120s Maximum time to wait for the next request on a keep-alive connection
SHUTDOWN_TIMEOUT 15s Grace period for in-flight requests on shutdown

Storage

Variable Default Description
DATABASE_PATH ./storage/snackbox.db Path to the SQLite database file. Must be writable by the snackbox user. In Docker, set to /var/lib/snackbox/snackbox.db.
STORAGE_PATH ./storage/media Directory for uploaded media files. In Docker, set to /var/lib/snackbox/media.

Security

Variable Default Description
JWT_EXPIRY 15m Access token lifetime. Parsed with Go's time.ParseDuration (e.g. 15m, 1h). Refresh-token rotation handles session continuity - shorten this value for higher security, lengthen it only if your frontend cannot implement token refresh.
JWT_REFRESH_EXPIRY 168h Refresh token lifetime (default: 7 days).
CORS_ALLOWED_ORIGINS (empty) Comma-separated list of allowed CORS origins. Empty (the default) disables all cross-origin access. Use * to allow any origin (development only). See CORS configuration for details.
MEDIA_BASE_URL - Base URL prepended to the url field in media responses. When set, media URLs are returned as absolute URLs (e.g. https://cdn.example.com/media/images/x.png). Leave unset to return relative paths. See Media file serving for details.
AUTH_RATE_LIMIT 10 Maximum login, refresh, and change-password requests per second per IP address. Burst is automatically set to rate/2 + 1 (default burst: 6).
RATE_LIMIT_MAX_IPS 100000 Maximum number of per-IP state entries the rate limiter retains between cleanup cycles. When the cap is reached, requests from new IPs are rejected with 429 immediately rather than allocating a new entry, bounding memory growth under a distributed flood. Set to 0 to disable the cap (not recommended for production).
TRUSTED_PROXIES - Comma-separated list of upstream proxy IP addresses or CIDR ranges whose X-Forwarded-For / X-Real-IP headers are trusted for client IP extraction. Used by the rate limiter and access log. Leave unset when not running behind a reverse proxy. Example: 10.0.0.1 or 10.0.0.0/8,172.16.0.0/12.
MAX_UPLOAD_SIZE 33554432 Maximum size of a single media upload in bytes (default: 32 MB).
BCRYPT_COST 12 bcrypt work factor for password hashing. Must be between 4 and 31. Default is 12 - one step above bcrypt.DefaultCost (10), providing roughly 4× more work per hash. Increase for higher security at the cost of slower logins.
HSTS_MAX_AGE 0 max-age value in seconds for the Strict-Transport-Security header. 0 (default) disables the header. Only enable on deployments exclusively reachable over HTTPS - sending HSTS over plain HTTP permanently locks browsers out of the site. Note: browsers cache the HSTS policy for the full max-age duration even after the header is removed; setting this value and then reverting it can lock browsers out of your domain for up to a year. Example: 31536000 for one year.

Logging

Variable Default Description
LOG_LEVEL info Minimum log level. One of: debug, info, warn, error.
LOG_FORMAT json Log output format. One of: json (production) or text (development).
ACCESS_LOG true Enable HTTP access logging. Set to false to suppress per-request log lines.

LOG_FORMAT=text is recommended for local development; use json in production. JSON logs are compatible with log aggregation tools such as Loki/Promtail.

ACCESS_LOG is independent of LOG_LEVEL - disabling it suppresses access log lines regardless of the configured level.

Roles

Snackbox enforces role-based access control on all write and administrative endpoints. Every user account is assigned exactly one role.

Role Description
admin Full access - manage all resources, users, and site settings
author Can create, edit, and delete their own pages and posts; can upload media files. Cannot delete media (admin only), manage users, settings, or other authors' content
editor Can create, edit, and delete any page or post regardless of authorship; cannot upload or delete media, manage users, site settings, navigation, or social accounts
member Authenticated identity with read access to public endpoints; no write capabilities in 1.x

Roles are assigned when creating or updating a user account. The initial admin user is always created with the admin role.

The editor role

The editor role sits between author and admin. An editor can create, update, and delete any post or page regardless of who authored it. This makes editors useful for content teams where multiple people need to manage a shared pool of content without granting full administrative access.

Editors cannot manage user accounts, site settings, navigation, or social accounts - those actions still require admin. Editors also cannot upload or delete media files - uploads require author or admin, and deletions require admin.

The member role

The member role exists as a forward-compatible identity for registered users. In 1.x, a member can authenticate and read their own profile - capabilities that are identical to what an unauthenticated user can do on public endpoints. The distinction is identity: a member has a persistent account that the system recognizes.

Assigning the member role now lets operators build a registered-user base whose permissions will expand automatically in 2.0.0 without requiring user re-creation or data migration. Planned 2.0.0 capabilities include posting comments and subscribing to a newsletter.

Do not confuse member with author. Authors can create and manage content; members cannot.

Operations

Logs

Logs are written to stderr using log/slog. The default format is JSON, which is compatible with log aggregation tools such as Loki/Promtail. Set LOG_FORMAT=text for human-readable output during local development.

On startup, a snackbox ready log entry is emitted once the server is accepting connections, containing the commit hash, dirty flag, schema version, and active addresses:

{"time":"2026-01-01T00:00:00Z","level":"INFO","msg":"snackbox ready","commit":"abc1234","dirty":false,"schema":"0010","listen":":8080","metrics":":9091","db":"/data/snackbox.db"}

HTTP access log entries (when ACCESS_LOG=true) use the key "msg":"access":

{"time":"2026-01-01T00:00:01Z","level":"INFO","msg":"access","method":"GET","path":"/api/v1/posts","status":200,"duration_ms":3,"remote_addr":"10.0.0.1:12345"}

Under Docker:

docker compose logs -f

Under systemd:

journalctl -u snackbox -f

Log aggregation with Loki

Snackbox emits structured JSON logs to stderr, making it a natural fit for Grafana Loki. The recommended collection agent is Promtail, which reads container log files and ships them to Loki.

A ready-to-use Docker Compose override is provided at deployments/docker/docker-compose.loki.yml. It adds Loki and Promtail as side-car services alongside Snackbox.

Quick start

docker compose \
  -f deployments/docker/docker-compose.yml \
  -f deployments/docker/docker-compose.loki.yml \
  up -d

Loki is available at http://localhost:3100. Add it as a data source in Grafana using the URL http://loki:3100 (container-to-container) or http://localhost:3100 (host-to-container).

Useful LogQL queries

# All snackbox log lines
{job="snackbox"}

# Errors only
{job="snackbox"} | json | level="ERROR"

# Authentication failures
{job="snackbox"} | json | msg="login failed"

# Rate-limited requests
{job="snackbox"} | json | msg="rate limit exceeded"

# HTTP access log (when ACCESS_LOG=true)
{job="snackbox"} | json | msg="access"

Systemd (bare-metal)

For systemd deployments, use the Promtail systemd journal target instead of the Docker log driver. Point Promtail at the journal unit snackbox.service and forward to your Loki instance.

Metrics

Snackbox exposes a Prometheus-compatible /metrics endpoint on METRICS_ADDR (default :9091). Available metrics:

Metric Type Description
http_requests_total Counter Total HTTP requests labeled by method, path, and status_code
http_request_duration_seconds Histogram HTTP request duration in seconds labeled by method, path, and status_code
auth_failures_total Counter Authentication failures labeled by reason (missing_token, invalid_token, invalid_credentials)
media_upload_bytes Histogram Size of successfully uploaded media files in bytes

Important: Do not expose the metrics port to the public internet. Restrict it to your monitoring network or bind it to loopback (METRICS_ADDR=127.0.0.1:9091). Snackbox logs a startup warning when METRICS_ADDR resolves to all interfaces (e.g. the default :9091) to remind operators to firewall the port.

Media file serving

Snackbox serves uploaded files directly over HTTP at the /media/ path - no reverse proxy is required to make media accessible. Files are read from STORAGE_PATH (default: ./storage/media) by Go's built-in file server.

To make the url field in media responses return usable absolute URLs, set MEDIA_BASE_URL to the public origin of your deployment:

MEDIA_BASE_URL=https://app.example.com

Media responses will then return absolute URLs such as https://app.example.com/media/images/<uuid>-photo.png.

Note: If MEDIA_BASE_URL is not set, the url field in media responses is a relative path (e.g. /media/images/<uuid>-photo.png). This is sufficient for same-origin access but unusable by a frontend hosted on a different domain.

Optional: offload media through a reverse proxy

For production deployments you may want to serve media files directly from nginx or Caddy to take advantage of kernel-level sendfile, long-lived caching headers, and CDN integration - without the requests passing through the Go process. This is a performance optimization, not a requirement.

nginx - add a location block before the proxy_pass block so the web server short-circuits media requests:

location /media/ {
    alias /var/lib/snackbox/media/;
    expires 30d;
    add_header Cache-Control "public, immutable";
}

Caddy - add a file_server directive before the reverse proxy handler:

handle /media/* {
    root * /var/lib/snackbox/media
    rewrite * {path}
    file_server
}

When using this pattern, make sure STORAGE_PATH on the host matches the path configured in the proxy (/var/lib/snackbox/media in the examples above).

CORS configuration

CORS_ALLOWED_ORIGINS controls which browser origins may make cross-origin requests to the API.

Value Behavior
(empty) Default. All cross-origin requests are rejected. No CORS headers are set. Same-origin and server-side access are unaffected.
* Any origin is accepted. The Access-Control-Allow-Origin header reflects the incoming Origin value rather than a literal *. Access-Control-Allow-Credentials is not set - credentialed cross-origin requests are not supported in wildcard mode. See the security note below.
Explicit list Only the listed origins receive CORS headers. Requests from other origins are rejected by the browser.

Security note: When using *, Access-Control-Allow-Credentials is not set - combining a wildcard echo with credentials would grant every origin full credentialed access, bypassing the browser's own wildcard-credential protection. Use an explicit origin list if your frontend must send cookies or Authorization headers cross-origin.

By default CORS is disabled (empty value - all cross-origin requests are rejected). Set this to your frontend origin to enable cross-origin access:

CORS_ALLOWED_ORIGINS=https://app.example.com

Multiple origins are supported as a comma-separated list:

CORS_ALLOWED_ORIGINS=https://app.example.com,https://staging.example.com

Note: Snackbox does not need a CORS origin set if you access the API only from server-side code or curl - CORS is a browser security mechanism. It only matters when a web page hosted on one origin calls the API on another.

Health checks

GET /health (served at the root path, not under /api/v1) returns a JSON object indicating whether the server is ready to handle requests:

{"status": "ok", "timestamp": "2025-01-15T12:00:00Z"}

The endpoint performs a lightweight database ping before responding. A 200 ok means the database is reachable. A 503 Service Unavailable means the database ping failed - the server process is running but not operational.

Docker HEALTHCHECK

The provided docker-compose.yml already wires this up:

healthcheck:
  test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 10s

The container transitions to unhealthy after three consecutive failures, at which point Docker's restart policy (unless-stopped) will restart it automatically.

systemd readiness probe

To delay dependent services until Snackbox is accepting connections, add an ExecStartPost probe to the unit file:

ExecStartPost=/bin/sh -c 'for i in $(seq 1 10); do wget -qO- http://127.0.0.1:8080/health && exit 0 || sleep 1; done; exit 1'

Load balancer / reverse proxy

Point your load balancer's health check at GET http://127.0.0.1:8080/health. Remove the backend from rotation on any non-200 response.

Updating

Before upgrading: always back up your database and media directory first. See Backup. Migrations are append-only - they cannot be rolled back automatically. A backup is your only recovery path if a migration causes problems.

Under Docker:

# 1. Back up first
docker compose exec snackbox snackbox backup --output /var/backups/snackbox

# 2. Pull the new image and restart
docker compose pull
docker compose up -d

Under systemd:

# 1. Back up first
snackbox backup --output /var/backups/snackbox

# 2. Replace the binary and restart
sudo systemctl stop snackbox
sudo install -m 0755 snackbox-linux-amd64 /usr/local/bin/snackbox
sudo systemctl start snackbox

Migrations run automatically on startup.

If the server refuses to start after an upgrade, check the logs for a migration error ("migration failed") or a configuration error ("JWT_SECRET too short"). Fix the root cause, then restart - migrations are idempotent and safe to re-run.

Rollback note: the binary can be rolled back by reinstalling the previous version. However, because migrations are append-only, the old binary will see schema columns it does not understand and may behave incorrectly. The safest rollback path is to restore the database and media directory from the backup you took before the upgrade, then reinstall the previous binary.

Reverse proxy

A reverse proxy is not required to run Snackbox - the API and media endpoints are served directly on port 8080. However, placing nginx or Caddy in front is recommended for production deployments to handle TLS termination, HTTP/2, and long-lived connection management.

The examples below show a minimal TLS-terminating configuration that proxies all requests to a locally-bound Snackbox instance. Adapt the domain name, certificate paths, and port to your environment.

nginx

server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    # Optional: serve media files directly from disk, bypassing the Go process
    location /media/ {
        alias /var/lib/snackbox/media/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location / {
        proxy_pass         http://127.0.0.1:8080;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }
}

Set TRUSTED_PROXIES=127.0.0.1 so the rate limiter and access log see the real client IP from X-Forwarded-For rather than the loopback address.

Caddy

Caddy handles TLS automatically via Let's Encrypt. A minimal Caddyfile:

app.example.com {
    # Optional: serve media files directly from disk, bypassing the Go process
    handle /media/* {
        root * /var/lib/snackbox/media
        rewrite * {path}
        file_server
    }

    reverse_proxy 127.0.0.1:8080
}

With Caddy the default TRUSTED_PROXIES=127.0.0.1 applies the same way.

CLI reference

The snackbox binary exposes one-off operational tasks as subcommands. None of the subcommands start the HTTP server. All accept --config FILE to load a non-default .env file and --help for usage information.

snackbox migrate

Applies any pending SQL migrations and exits. Useful in automation scripts where you want to run migrations before starting the server, or to verify that all migrations are up to date.

snackbox migrate
snackbox migrate --config /etc/snackbox/.env

snackbox create-admin

Creates an admin user account. Exits non-zero if a user with the given email already exists.

snackbox create-admin \
  --name  "Admin" \
  --email admin@example.com \
  --password <strong-password>
Flag Required Description
--name yes Display name
--email yes Email address
--password yes Password (minimum 8 characters)

snackbox seed-fixtures

Loads the Birkenweg Bakery demo dataset into the database. The command is idempotent - it does nothing if the seed post already exists. See the Seed Fixtures section of the Developer Manual for a full description of what gets created.

snackbox seed-fixtures

snackbox publish-due

Publishes all posts and pages whose publish_at timestamp is in the past. The command is idempotent - safe to run repeatedly.

snackbox publish-due

The API server does not invoke this command automatically. Use one of the provided deployment artifacts to run it on a schedule:

  • systemd timer - see step 6 in the Binary (systemd) deploy section.
  • Docker / Compose - see Scheduled publishing in the Docker deploy section.

As a fallback, a raw cron entry also works:

# /etc/cron.d/snackbox-publish  -  every minute
* * * * * snackbox /usr/local/bin/snackbox publish-due --config /etc/snackbox/.env

snackbox backup

See the Backup section below for full documentation.

Backup

Always back up both the database and the media directory together - they must stay in sync because the database holds metadata (filenames, alt text, MIME types) that references files on disk.

The built-in backup subcommand creates a timestamped database snapshot using SQLite's VACUUM INTO and a gzip-compressed tar archive of the media directory. Both files are written to the output directory (default: ./backups). The server does not need to be stopped - VACUUM INTO captures a point-in-time copy of the live database.

snackbox backup
# produces:
#   ./backups/snackbox-20250101-120000.db
#   ./backups/snackbox-media-20250101-120000.tar.gz

Write backup files to a custom directory:

snackbox backup --output /var/backups/snackbox

Pass --config if you use a non-default .env file location:

snackbox backup --config /etc/snackbox/.env --output /var/backups/snackbox

The command exits non-zero on any failure. Wire it into a cron job or systemd timer for automated backups:

# /etc/cron.d/snackbox-backup   -  daily at 02:00
0 2 * * * snackbox /usr/local/bin/snackbox backup --output /var/backups/snackbox

Manual fallback

If you need to back up without the binary (e.g. from a container management tool), use the approaches below.

Under Docker, copy the files directly from the container volume:

docker compose cp snackbox:/var/lib/snackbox/snackbox.db ./snackbox-backup.db
docker compose cp snackbox:/var/lib/snackbox/media ./snackbox-media-backup

Under systemd, use SQLite's .backup command and copy the media directory:

sqlite3 /var/lib/snackbox/snackbox.db ".backup '/var/backups/snackbox-backup.db'"
cp -r /var/lib/snackbox/media /var/backups/snackbox-media-backup

Troubleshooting

Server exits immediately with "JWT_SECRET too short"

Symptom: The process starts and exits within milliseconds. Logs contain:

level=ERROR msg="JWT_SECRET must be at least 32 characters"

Cause: JWT_SECRET is set but shorter than 32 characters, or the .env file is not being read.

Fix: Generate a sufficiently long secret and set it in .env:

openssl rand -hex 32   # produces a 64-character hex string

Verify the .env file is in the working directory (or pass --config /path/to/.env).


"database is locked" errors under write contention

Symptom: Occasional 500 responses on write endpoints, logs contain:

level=ERROR msg="database is locked"

Cause: SQLite allows only one writer at a time. In WAL mode this is very unlikely under normal load, but can occur if a long-running read transaction holds a shared lock while a write is attempted, or if a second process (e.g. a stray sqlite3 shell session) has the database open.

Fix: - Ensure only one Snackbox process is writing to the database at a time. - Close any sqlite3 shell sessions or other tools with the database open. - If running backup scripts, use snackbox backup which uses VACUUM INTO and does not hold a lock on the live database.


ADMIN_* variables are silently ignored on re-deploy

Symptom: You change ADMIN_EMAIL or ADMIN_PASSWORD in .env, restart the server, and the old credentials still work. No error is logged.

Cause: The bootstrap step that reads ADMIN_* variables only runs when no admin user exists in the database. On every subsequent startup those variables are ignored.

Fix: To change admin credentials after first run, use the API:

# Change password via the API (requires a valid admin token)
curl -X POST http://localhost:8080/api/v1/auth/change-password \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"current_password":"old","new_password":"new-strong-password"}'

Alternatively, use snackbox create-admin to create a second admin account, then delete the old one via the Users API.


CORS requests fail even though CORS_ALLOWED_ORIGINS is set

Symptom: Browser console shows a CORS error. The API works fine from curl.

Cause: The most common causes are: - CORS_ALLOWED_ORIGINS is unset (the default disables all cross-origin access). - The value includes a trailing slash (https://app.example.com/) - origins must not have a trailing slash. - The frontend origin does not exactly match the listed value (scheme, host, and port must all match).

Fix: Set the variable to the exact origin of your frontend application:

CORS_ALLOWED_ORIGINS=https://app.example.com

Restart the server after changing the value. If you need to debug, check whether the Access-Control-Allow-Origin header is present in the response headers (it will be absent if the origin is not matched).


All API requests return 401 Unauthorized after a binary upgrade

Symptom: All existing sessions stop working immediately after restarting the server. Clients receive 401 Unauthorized.

Cause: JWT_SECRET is not set in .env. Snackbox generates a random ephemeral secret at each startup. Tokens issued before the restart were signed with the previous secret and are now invalid.

Fix: Set a stable JWT_SECRET in .env before starting the server:

JWT_SECRET=$(openssl rand -hex 32)

Add it to .env and restart. Future restarts will reuse the same secret and existing tokens will remain valid until they expire naturally.


Media url field returns a relative path instead of an absolute URL

Symptom: The url field in GET /api/v1/media responses looks like /media/images/<uuid>-photo.png instead of https://app.example.com/media/images/<uuid>-photo.png. Frontend clients on a different origin cannot load the file.

Cause: MEDIA_BASE_URL is not set. Without it, Snackbox returns a relative path.

Fix: Set MEDIA_BASE_URL to the public origin of your deployment:

MEDIA_BASE_URL=https://app.example.com

Existing media records are updated dynamically - the url field is constructed at query time, so setting this variable and restarting is sufficient. No database migration is required.