Production Deployment
End-to-end guide for running AppStoreCat in production: reverse proxy with TLS, environment hardening, backups, log retention, firewall, and rollback. This guide assumes a single-host Docker Compose deployment, which is what the project is designed and tested for.
For multi-host or Kubernetes deployments, the published images at ghcr.io/appstorecat/{server,web,scraper-ios,scraper-android} work but you’ll need to adapt the orchestration yourself.
Architecture in production
Section titled “Architecture in production” internet │ ▼ ┌─────────────────────┐ │ Reverse proxy + TLS │ (Caddy / Nginx / Traefik) │ ports 80, 443 │ └─────────┬────────────┘ │ ┌───────────┴────────────┐ │ │ ▼ ▼appstorecat-web:7461 appstorecat-server:7460 │ ├─▶ appstorecat-mysql:3306 (internal — host port: 7464) ├─▶ appstorecat-redis:6379 (internal — host port: 7465) ├─▶ appstorecat-scraper-ios:7462 (internal only) └─▶ appstorecat-scraper-android:7463 (internal only)Only ports 7460 (API) and 7461 (web) need to be reachable from the proxy. The rest stay on the Docker network.
Requirements
Section titled “Requirements”- Linux server, x86_64 or arm64
- Docker Engine 24+ with Compose v2 plugin
- 2 vCPU, 4 GB RAM minimum (sync queues + MySQL + Redis comfortably; tested at this size)
- 20 GB disk for the OS + Docker + 30 days of MySQL data on a few hundred tracked apps; grow with usage
- A domain (or two subdomains: one for API, one for web)
- Outbound internet access (the scrapers fetch from iTunes and Google Play)
Step 1 — Pull the repo and prepare .env
Section titled “Step 1 — Pull the repo and prepare .env”ssh you@your-servergit clone --branch v1.2.0 https://github.com/appstorecat/appstorecat.gitcd appstorecatcp .env.production.example .envEdit .env with production values. Fill in everything — defaults from the example are not safe.
APP_NAME=AppStoreCatAPP_ENV=productionAPP_KEY= # see step 2APP_DEBUG=falseAPP_VERSION=1.2.0
# URLs (HTTPS — the proxy terminates TLS)APP_URL=https://api.appstore.exampleFRONTEND_URL=https://appstore.example
# Auth — required when frontend and backend are on different subdomainsSANCTUM_STATEFUL_DOMAINS=appstore.example,www.appstore.exampleSESSION_DOMAIN=.appstore.exampleSESSION_SECURE_COOKIE=trueSESSION_SAME_SITE=lax
# Scrapers (internal Docker network)APPSTORE_API_URL=http://appstorecat-scraper-ios:7462GPLAY_API_URL=http://appstorecat-scraper-android:7463
# Database — generate strong passwords (see step 2)DB_DATABASE=appstorecatDB_USERNAME=appstorecatDB_PASSWORD= # see step 2MYSQL_ROOT_PASSWORD= # see step 2
# Queue + cache — production uses database/file (no Redis on the host network)QUEUE_CONNECTION=databaseCACHE_STORE=file
# Logging — stderr is container-friendly; use warning in productionLOG_CHANNEL=stderrLOG_LEVEL=warning
# Swagger off in productionL5_SWAGGER_GENERATE_ALWAYS=false
# Internal ports — 746x series across the stackBACKEND_PORT=7460FRONTEND_PORT=7461APPSTORE_API_PORT=7462GPLAY_API_PORT=7463FORWARD_DB_PORT=7464FORWARD_REDIS_PORT=7465See Environment Variables for the full reference, especially the Workers section if you want to tune queue throughput.
Step 2 — Generate secrets
Section titled “Step 2 — Generate secrets”# APP_KEY (32-byte base64) — Laravel will refuse to boot without itdocker run --rm -v "$(pwd)":/app -w /app php:8.4-cli php -r "echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;"
# Strong DB passwords (28 alphanumeric chars)openssl rand -base64 28 | tr -d '+/=' | head -c 28openssl rand -base64 28 | tr -d '+/=' | head -c 28Paste the values into .env. Never commit .env to git — it’s .gitignored for a reason.
Step 3 — Deploy
Section titled “Step 3 — Deploy”docker compose -f docker-compose.production.yml pulldocker compose -f docker-compose.production.yml up -dWait ~30 seconds, then run migrations:
docker compose -f docker-compose.production.yml exec appstorecat-server php artisan migrate --forcedocker compose -f docker-compose.production.yml exec appstorecat-server php artisan db:seed --force--force is required by Laravel in production env to confirm you mean it.
Verify everything is healthy:
docker compose -f docker-compose.production.yml ps# All services should be "running" or "healthy"
curl -f http://localhost:7460/api/v1/countries# Should return JSON; if not, check 'docker compose logs appstorecat-server'Step 4 — Reverse proxy + TLS
Section titled “Step 4 — Reverse proxy + TLS”Pick one. All three terminate TLS, forward to the internal ports, and handle Let’s Encrypt automatically.
Caddy (simplest — automatic TLS)
Section titled “Caddy (simplest — automatic TLS)”/etc/caddy/Caddyfile:
appstore.example { reverse_proxy localhost:7461 encode gzip}
api.appstore.example { reverse_proxy localhost:7460 encode gzip
# Sanctum needs the original Host header header_up Host {host} header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme}}sudo systemctl reload caddyCaddy fetches Let’s Encrypt certs automatically on first request. Done.
Nginx + certbot
Section titled “Nginx + certbot”/etc/nginx/sites-available/appstorecat:
upstream appstorecat_web { server 127.0.0.1:7461; }upstream appstorecat_server { server 127.0.0.1:7460; }
server { listen 80; server_name appstore.example api.appstore.example; return 301 https://$host$request_uri;}
server { listen 443 ssl http2; server_name appstore.example;
ssl_certificate /etc/letsencrypt/live/appstore.example/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/appstore.example/privkey.pem;
client_max_body_size 20M;
location / { proxy_pass http://appstorecat_web; 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; }}
server { listen 443 ssl http2; server_name api.appstore.example;
ssl_certificate /etc/letsencrypt/live/appstore.example/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/appstore.example/privkey.pem;
client_max_body_size 20M;
location / { proxy_pass http://appstorecat_server; 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; }}sudo ln -s /etc/nginx/sites-available/appstorecat /etc/nginx/sites-enabled/sudo certbot --nginx -d appstore.example -d api.appstore.examplesudo nginx -t && sudo systemctl reload nginxTraefik (Docker labels)
Section titled “Traefik (Docker labels)”If Traefik is already running on the same host with Docker provider enabled, add labels to docker-compose.production.yml:
services: appstorecat-web: labels: - "traefik.enable=true" - "traefik.http.routers.appstorecat-web.rule=Host(`appstore.example`)" - "traefik.http.routers.appstorecat-web.entrypoints=websecure" - "traefik.http.routers.appstorecat-web.tls.certresolver=letsencrypt" - "traefik.http.services.appstorecat-web.loadbalancer.server.port=7461"
appstorecat-server: labels: - "traefik.enable=true" - "traefik.http.routers.appstorecat-api.rule=Host(`api.appstore.example`)" - "traefik.http.routers.appstorecat-api.entrypoints=websecure" - "traefik.http.routers.appstorecat-api.tls.certresolver=letsencrypt" - "traefik.http.services.appstorecat-api.loadbalancer.server.port=7460"Both containers must be on Traefik’s network.
Step 5 — Firewall
Section titled “Step 5 — Firewall”The scraper services (7462, 7463), MySQL host-side port (7464), and Redis host-side port (7465) must not be publicly reachable. With ufw:
sudo ufw default deny incomingsudo ufw default allow outgoingsudo ufw allow sshsudo ufw allow 80/tcpsudo ufw allow 443/tcpsudo ufw enableIf you need to expose MySQL temporarily for a migration tool, tunnel through SSH instead of opening the port:
ssh -L 7464:localhost:7464 you@server# then connect locally to 127.0.0.1:7464Step 6 — Backups
Section titled “Step 6 — Backups”Daily MySQL dump rotated by date. Save as /usr/local/bin/appstorecat-backup.sh:
#!/bin/bashset -euo pipefail
BACKUP_DIR=/var/backups/appstorecatRETENTION_DAYS=14TIMESTAMP=$(date -u +%Y-%m-%dT%H-%M-%SZ)COMPOSE_DIR=/home/you/appstorecat
mkdir -p "$BACKUP_DIR"
cd "$COMPOSE_DIR"docker compose -f docker-compose.production.yml exec -T appstorecat-mysql \ sh -c 'exec mysqldump --single-transaction --quick --lock-tables=false \ -u root -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' \ | gzip -9 > "$BACKUP_DIR/appstorecat-$TIMESTAMP.sql.gz"
# Optional: ship to S3 / B2 / Backblaze# aws s3 cp "$BACKUP_DIR/appstorecat-$TIMESTAMP.sql.gz" s3://my-bucket/appstorecat/
find "$BACKUP_DIR" -name 'appstorecat-*.sql.gz' -mtime +$RETENTION_DAYS -delete
echo "Backup complete: $BACKUP_DIR/appstorecat-$TIMESTAMP.sql.gz"sudo chmod +x /usr/local/bin/appstorecat-backup.shsudo crontab -e# Add:0 3 * * * /usr/local/bin/appstorecat-backup.sh >> /var/log/appstorecat-backup.log 2>&1Restore from backup:
gunzip -c /var/backups/appstorecat/appstorecat-2026-04-26T03-00-00Z.sql.gz \ | docker compose -f docker-compose.production.yml exec -T appstorecat-mysql \ sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"'Step 7 — Log rotation
Section titled “Step 7 — Log rotation”The container logs to stderr, which Docker captures. Limit log file size in docker-compose.production.yml (or globally in /etc/docker/daemon.json):
services: appstorecat-server: logging: driver: json-file options: max-size: "20m" max-file: "5"Apply globally instead by editing /etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "20m", "max-file": "5" }}Then sudo systemctl restart docker (this restarts your containers — schedule a maintenance window).
Upgrading
Section titled “Upgrading”cd appstorecat
# 1. Note the current version (for rollback)CURRENT=$(git describe --tags --abbrev=0)echo "Current: $CURRENT"
# 2. Take a backupsudo /usr/local/bin/appstorecat-backup.sh
# 3. Pull the new releasegit fetch --tagsgit checkout v1.3.0 # replace with the target version
# 4. Pull new imagesdocker compose -f docker-compose.production.yml pull
# 5. Applydocker compose -f docker-compose.production.yml up -d
# 6. Migratedocker compose -f docker-compose.production.yml exec appstorecat-server php artisan migrate --force
# 7. Verifycurl -f https://api.appstore.example/api/v1/countriesRollback
Section titled “Rollback”If step 7 fails or the new version misbehaves:
# Revert containers to the previous taggit checkout "$CURRENT"docker compose -f docker-compose.production.yml pulldocker compose -f docker-compose.production.yml up -d
# If migrations need rolling back, restore from the backup taken in step 2gunzip -c /var/backups/appstorecat/appstorecat-<timestamp>.sql.gz \ | docker compose -f docker-compose.production.yml exec -T appstorecat-mysql \ sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"'Most upgrades are forward-compatible at the schema level — the rollback path is mainly for pre-flight safety, not routine use.
Operations
Section titled “Operations”Queue workers
Section titled “Queue workers”The production server image runs supervisord which keeps php artisan queue:work processes alive across all platform-separated queues. Adjust concurrency via SUPERVISOR_QUEUE_NUMPROCS in .env (default 2).
# Restart workers (e.g. after a deploy)docker compose -f docker-compose.production.yml exec appstorecat-server php artisan queue:restart
# Inspect failed jobsdocker compose -f docker-compose.production.yml exec appstorecat-server php artisan queue:failed
# Retry onedocker compose -f docker-compose.production.yml exec appstorecat-server php artisan queue:retry <uuid>
# Retry alldocker compose -f docker-compose.production.yml exec appstorecat-server php artisan queue:retry allScheduler
Section titled “Scheduler”Laravel’s scheduler runs inside the same container (also via supervisord). It dispatches the 20-minute tracked-app sync, daily chart snapshots, and ReconcileFailedItemsJob. No host-level cron needed.
To disable (e.g. when running scheduler externally):
SCHEDULER_ENABLED=falseTailing logs
Section titled “Tailing logs”# All servicesdocker compose -f docker-compose.production.yml logs -f --tail=200
# One servicedocker compose -f docker-compose.production.yml logs -f appstorecat-serverChecking health
Section titled “Checking health”docker compose -f docker-compose.production.yml ps
curl -fsI https://api.appstore.example/api/v1/countriescurl -fsI https://appstore.exampleSecurity checklist
Section titled “Security checklist”-
APP_DEBUG=false -
APP_ENV=production -
APP_KEYset (random, 32 bytes, base64-encoded) -
DB_PASSWORDandMYSQL_ROOT_PASSWORDare strong and distinct -
SANCTUM_STATEFUL_DOMAINSset to your actual frontend domain(s) -
SESSION_SECURE_COOKIE=true - HTTPS in front of both web and API (no plain HTTP)
- Firewall allows only 22, 80, 443 inbound
- Scraper ports (
7462,7463), MySQL host port (7464), Redis host port (7465) NOT exposed -
L5_SWAGGER_GENERATE_ALWAYS=false - Daily backups running and tested (do a restore drill once)
- Docker log rotation configured (or you’ll fill the disk)
- Server packages auto-update (e.g.
unattended-upgradeson Debian/Ubuntu)