Manual Step-by-Step Installation
This is the manual walkthrough of everything the Quick Install script (scripts/install.sh) automates — useful if you want to understand or customize what it's doing, or if you're not using Docker/Ubuntu and need to adapt the steps.
Docker deployment is the simplest approach and ensures consistency across environments.
1. Initial Setup
Set up your server however you would normally host. See the instructions in Hetzner Cloud with Docker for specific instructions on a basic installation server.
Connect to your VPS and create a deployment directory:
ssh root@your-vps-ip
mkdir -p /opt/mirlo
cd /opt/mirlo2. Clone the Repository
git clone https://github.com/funmusicplace/mirlo.git .3. Configure Environment Variables
The easiest way is the setup script, which prompts for your instance's public URL and generates random credentials for PostgreSQL, Redis, MinIO and the JWT secrets. It writes both .env and client/.env, with DATABASE_URL derived from the same generated values so they can't disagree, and sets NODE_ENV=production for https domains (development otherwise — see the NODE_ENV notes below):
bash scripts/generate-env.shOr non-interactively:
MIRLO_DOMAIN=https://yourdomain.com bash scripts/generate-env.shAfterwards, open .env to fill in anything optional (Stripe keys, S3 credentials if you're not using MinIO, etc.).
Note: the database, Redis and MinIO bake their credentials into their data volumes on first start, so run this before the first
docker compose up. The script refuses to overwrite an existing.envfor the same reason.
Alternatively, configure it manually:
cp .env.example .env
nano .envKey variables to set:
# Basic Configuration
# NODE_ENV does NOT choose the storage backend (that's decided by the S3
# variables below). What it does change: with NODE_ENV=production, auth
# cookies are set secure + strict (right for a public https instance, but
# breaks login over plain http), and unconfigured email is silently dropped
# instead of being caught by MailHog (see step 4). The compose files default
# to "development" when it's unset.
NODE_ENV=production
PORT=3000
API_DOMAIN=https://yourdomain.com
STATIC_MEDIA_HOST=https://yourdomain.com
# Security
JWT_SECRET=your-secure-jwt-secret-here
REFRESH_TOKEN_SECRET=your-secure-refresh-token-secret-here
# Database
DATABASE_URL="postgresql://mirlo:secure-password@pgsql:5432/mirlo?schema=public"
POSTGRES_USER=mirlo
POSTGRES_PASSWORD=secure-password
# Redis
REDIS_PASSWORD=secure-redis-password
###
# Choose one: MinIO or an S3 Service. S3 Services are recommended in production. https://mirlo.space uses Backblaze as an S3 Service.
# Mirlo uses the S3 service whenever both S3 credentials below are set, and
# MinIO otherwise (set STORAGE_BACKEND=minio|s3 to override explicitly).
###
# MinIO
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=secure-minio-password
# Backblaze B2 / S3 / Hetzner Object Storage
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=
S3_ENDPOINT=
# Optional: Stripe Integration
STRIPE_KEY=your-stripe-key
STRIPE_WEBHOOK_CONNECT_SIGNING_SECRET=If configuring manually, also create client/.env — the frontend bakes these values in at build time:
VITE_API_DOMAIN=https://yourdomain.com
VITE_CLIENT_DOMAIN=https://yourdomain.comNote: if you're using Stripe, you'll also need to register two webhook endpoints once your domain is live — see Register Stripe Webhooks. That step isn't automated by
install.sheither.
4. Build and Start Services
Warning: make sure
.envis final before the first start — PostgreSQL bakes its credentials into its data volume (./data/pgsql) when it first initializes, and changing passwords in.envafterwards causes authentication failures (see Troubleshooting). Also note thatdocker compose restartdoes not re-read.env: after any.envchange, rundocker compose up -dso changed containers get recreated.
For a production server, use the production compose file — the default docker-compose.yml publishes PostgreSQL and Redis on host ports, which on most VPSes means exposing them to the internet (see Hetzner Firewall). Add this to .env so every plain docker compose command picks it up:
COMPOSE_FILE=docker-compose.prod.ymldocker compose up -dVerify all services are running:
docker compose psYou should see 6 services: api, background, pgsql, redis, minio, mailhog
MailHog is a dev-only mail catcher. Until you configure a real email provider (Mailgun, Postmark, or SendGrid) in the admin settings panel, password resets and other transactional emails go nowhere: with NODE_ENV=production they're silently dropped, and otherwise they land in MailHog's own inbox at http://localhost:8025 (see Hetzner Firewall for how to reach it over SSH).
5. Initialize Database
Migrations run automatically every time the api container boots, so on a normal install there's nothing to do here. To run them by hand (e.g. to check they succeeded):
docker compose exec api yarn prisma:migrate:deployOptionally seed with demo data:
docker compose exec api yarn prisma:seed6. Create Admin User
Create an initial admin user for deployment management. Run the interactive setup script:
bash scripts/setup-admin.shThe script will prompt you for:
- Email address - Admin email (defaults to
admin@mirlo.local) - Password - Admin password
- Name - Admin display name (defaults to
Administrator)
Alternatively, if you prefer to pass credentials directly via environment variables:
ADMIN_EMAIL=admin@yourdomain.com ADMIN_PASSWORD=secure-password ADMIN_NAME="Admin User" docker compose exec api yarn setup:adminThis creates (or updates if exists) an admin user with full permissions and a confirmed email address. Credentials can be changed later through the admin panel.
7. Register the Instance's Client
The frontend identifies itself to the API as a "client", and user signup fails with a 400 This client does not exist error unless a row matching your domain exists in the Client table. Register it (idempotent, safe to re-run):
docker compose exec -e MIRLO_DOMAIN=https://yourdomain.com api yarn setup:client8. Build the Client
Build the React frontend for production:
docker compose exec api yarn client:buildThis compiles the client code with Vite and places it in the /client/dist directory, which Mirlo serves automatically.
On servers with 4GB RAM or less the build can run out of Node heap (FATAL ERROR: ... JavaScript heap out of memory). Give it more headroom (and consider adding swap):
docker compose exec -e NODE_OPTIONS=--max-old-space-size=3072 api yarn client:buildWarning: the built files live inside the api container's filesystem, not on the host. Whenever the api container is recreated (e.g.
docker compose up -dafter an.envchange, or--buildafter agit pull), the build is lost and the site serves nothing until you re-run it. To make builds survive recreates, bind-mount the dist directory: createdocker-compose.client.yml:yamlservices: api: volumes: - ./client/dist:/var/www/api/client/distand include it in every compose invocation, e.g. by extending the
COMPOSE_FILEline added to.envin step 4 toCOMPOSE_FILE=docker-compose.prod.yml:docker-compose.client.yml. Then run the client build once more; from then on it persists on the host.
9. Setup Reverse Proxy (Nginx)
Mirlo should sit behind a reverse proxy for SSL/TLS and better performance.
Install Nginx:
apt-get update
apt-get install -y nginx certbot python3-certbot-nginxCreate Nginx configuration (HTTP only initially):
nano /etc/nginx/sites-available/mirloserver {
server_name yourdomain.com www.yourdomain.com;
listen 80;
client_max_body_size 5G; # Allow large file uploads
location / {
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;
# Timeouts for large uploads
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
}Enable the site and test:
ln -s /etc/nginx/sites-available/mirlo /etc/nginx/sites-enabled/
nginx -t # Test configuration
systemctl restart nginx10. Setup SSL Certificate with Let's Encrypt
Now that Nginx is running on HTTP, use certbot to generate SSL certificates:
certbot --nginx -d yourdomain.com -d www.yourdomain.comCertbot will automatically:
- Validate your domain ownership via HTTP
- Generate SSL certificates
- Update your Nginx configuration with HTTPS settings
- Setup automatic certificate renewal
Verify the updated configuration:
nginx -t
systemctl restart nginx11. Verify Deployment
# Check application health
curl https://yourdomain.com/health
# View API logs
docker compose logs -f api
# View background worker logs
docker compose logs -f backgroundThat's it! For updates, backups and troubleshooting, see Maintenance and Updates in the main hosting guide.