A simple, reliable Node.js image upload and optimization server. Built as a fallback service for production use — accepts image uploads via API key authentication, converts to WebP format, strips metadata, and returns URLs for optimized images.
- API Key Authentication: Secure Bearer token authentication
- Automatic WebP Conversion: All uploads optimized to WebP (quality 80)
- Metadata Stripping: Removes EXIF data while preserving visual quality
- EXIF Orientation Handling: Auto-rotates images based on EXIF orientation
- Multiple Format Support: Accepts JPEG, PNG, WebP, and GIF
- File Size Limits: 10MB maximum upload size
- Configurable: Port, storage directory, and base URL via environment variables
- Production-Ready: Helmet security headers, explicit error handling, health checks
For local development or testing without nginx:
npm installCopy the example environment file and generate a secure API key:
cp .env.example .env
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Edit .env and paste your generated API key:
PORT=3000
API_KEY=<paste_your_generated_key_here>
UPLOAD_DIR=./uploads
BASE_URL=http://localhost:3000npm startThe server serves images directly at /images/* for standalone use (no nginx needed).
Run the interactive install script that will:
- ✅ Detect available ports
- ✅ Configure your domain
- ✅ Generate secure API key
- ✅ Create
.envfile with production settings - ✅ Install nginx configuration
- ✅ Set up systemd service
- ✅ Enable and start services
- ✅ Optionally install SSL certificate
- ✅ Save installation details to
installation-info.txt
./install.shFollow the prompts and the script handles everything! After installation, your API key and configuration will be saved in installation-info.txt.
If you prefer to set up manually or need custom configuration:
npm install --productionGenerate a secure API key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Create .env file with production settings:
PORT=3000
API_KEY=<paste_your_generated_key_here>
UPLOAD_DIR=/path/to/your/project/uploads
BASE_URL=https://images.yourdomain.comEndpoint: POST /upload
Authentication: Bearer token in Authorization header
Request:
curl -X POST \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "image=@path/to/your/image.jpg" \
http://localhost:3000/uploadResponse (201 Created):
{
"success": true,
"url": "http://localhost:3000/images/1709020800000-a1b2c3d4e5f6g7h8.webp",
"filename": "1709020800000-a1b2c3d4e5f6g7h8.webp",
"originalFormat": "jpeg",
"size": {
"width": 1920,
"height": 1080
}
}Error Responses:
400 Bad Request: Invalid file type, no file provided, or corrupted image401 Unauthorized: Missing authorization header403 Forbidden: Invalid API key413 Payload Too Large: File exceeds 10MB500 Internal Server Error: Image processing failure507 Insufficient Storage: Disk full
Endpoint: GET /health
Response:
{
"status": "ok",
"timestamp": "2026-02-26T12:34:56.789Z",
"uptime": 3600.5
}Note: If you used ./install.sh, this is already configured for you.
For production, nginx:
- Reverse proxies the upload endpoint to the Node.js service
- Serves optimized images as static files (better performance than Node.js)
- Handles SSL/TLS termination
Manual nginx config (/etc/nginx/sites-available/images.yourdomain.com):
server {
listen 80;
server_name images.yourdomain.com;
# Maximum upload size (must match or exceed Node.js limit)
client_max_body_size 10M;
# Upload endpoint - proxy to Node.js
location /upload {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
proxy_cache_bypass $http_upgrade;
# Timeout for large uploads
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
# Health check endpoint
location /health {
proxy_pass http://localhost:3000;
access_log off;
}
# Static image serving
location /images/ {
alias /path/to/your/uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
}Enable the site:
sudo ln -s /etc/nginx/sites-available/images.yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxNote: If you used ./install.sh, you were prompted to install SSL automatically.
To install or renew SSL certificate manually:
sudo certbot --nginx -d images.yourdomain.comNote: If you used ./install.sh, the service is already created and running.
To create the systemd service manually, create /etc/systemd/system/image-server.service:
[Unit]
Description=Image Upload and Optimization Server
After=network.target
[Service]
Type=simple
User=your-username
WorkingDirectory=/path/to/image-server
Environment="NODE_ENV=production"
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetEnable and start:
sudo systemctl daemon-reload
sudo systemctl enable image-server
sudo systemctl start image-server
sudo systemctl status image-serverClient Upload Request
↓
[nginx :80/443]
↓
POST /upload → [Node.js :3000]
↓
1. Authenticate API key
2. Validate image
3. Convert to WebP
4. Strip metadata
5. Save to filesystem
↓
Returns URL
↓
Client Access Image
↓
[nginx :80/443]
↓
GET /images/xxx.webp → [nginx serves static file]
Client Request
↓
[Node.js :3000]
↓
POST /upload → Process & Save
GET /images/* → Serve static files
Images are stored with randomized filenames to prevent:
- Filename collisions
- Path traversal attacks
- Predictable URLs
Format: <timestamp>-<random_hash>.webp
Example: 1709020800000-a1b2c3d4e5f6g7h8.webp
- API Key: 32+ character random hex string (use the generator command)
- Bearer Token: Constant-time comparison prevents timing attacks
- File Validation: MIME type whitelist + Sharp metadata validation
- Size Limits: 10MB maximum enforced by both Multer and nginx
- Path Safety: Random filenames prevent traversal, validated absolute paths
- Security Headers: Helmet middleware adds production-ready headers
- HTTPS: Always use SSL in production (nginx + Let's Encrypt)
# Check service status
sudo systemctl status image-server
# Start/stop/restart service
sudo systemctl start image-server
sudo systemctl stop image-server
sudo systemctl restart image-server# View real-time logs
sudo journalctl -u image-server -f
# View recent logs (last 100 lines)
sudo journalctl -u image-server -n 100
# View logs since boot
sudo journalctl -u image-server -b# Check upload directory size
du -sh /path/to/uploads
# Find large files
find /path/to/uploads -type f -size +5MSet up monitoring to ping /health endpoint regularly:
curl http://localhost:3000/healthERROR: API_KEY must be set in .env file and be at least 32 characters long.
Solution: Generate a new key with the provided command and add to .env
Error: listen EADDRINUSE: address already in use :::3000
Solution: Change PORT in .env or stop the conflicting service
Issue: Upload succeeds but URLs return 404 Solution:
- Check nginx configuration:
/etc/nginx/sites-available/yourdomain.com - Ensure
aliaspath in nginx matches yourUPLOAD_DIR - Verify nginx is running:
sudo systemctl status nginx - Test nginx config:
sudo nginx -t
Issue: Getting 404 on image URLs in standalone mode Solution: Make sure server.js has the static middleware enabled (should be by default)
Solution: Clear old images or increase disk space
# Find and remove images older than 90 days
find /path/to/uploads -type f -mtime +90 -deleteIf you used ./install.sh, check installation-info.txt for your API key and test command.
# Create a test image (if you don't have one)
curl -o test.jpg https://picsum.photos/800/600
# For production (with domain):
curl -X POST \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "image=@test.jpg" \
https://images.yourdomain.com/upload
# For development (localhost):
curl -X POST \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "image=@test.jpg" \
http://localhost:3000/upload
# The response will include the URL to access the optimized imagecurl -X POST \
-H "Authorization: Bearer invalid_key" \
-F "image=@test.jpg" \
http://localhost:3000/upload
# Expected: 403 Forbidden# Development:
curl http://localhost:3000/health
# Production:
curl https://images.yourdomain.com/health
# Expected: {"status":"ok",...}After running ./install.sh, these files are created:
.env- Your configuration with API keyinstallation-info.txt- Complete installation details (contains API key - keep secure!)/etc/nginx/sites-available/yourdomain.com- nginx configuration/etc/nginx/sites-enabled/yourdomain.com- Symlink to configuration/etc/systemd/system/image-server.service- systemd service file
.
├── .env # Configuration (not in git)
├── .env.example # Configuration template
├── .github/
│ └── copilot-instructions.md
├── .gitignore
├── install.sh # Automated installation script
├── installation-info.txt # Generated by install.sh (not in git)
├── node_modules/ # Dependencies
├── package.json
├── package-lock.json
├── README.md # This file
├── server.js # Main server file
└── uploads/ # Image storage (not in git)
MIT