Self-Hosting Cap on Synology NAS: A Complete Deployment Guide for the Open-Source Loom Alternative (with Local AI Integration)

Quick Intro: This guide provides a step-by-step tutorial on how to self-host Cap, an open-source Loom alternative, on your Synology NAS using Docker Compose. Achieve seamless screen recording and instant sharing, complete with solutions for 5 common deployment pitfalls and a full local large language model (LLM) integration — all with zero external API dependencies.


Why Choose Cap?

If you’ve ever used Loom, you’ll know the buttery-smooth experience of “record screen → auto-generate link → instant share.” However, Loom’s free tier comes with recording duration limits, its paid version can cost upwards of $10-20 USD per month, and your recordings are stored on a third-party server. For teams or individuals concerned about privacy, this can be a significant drawback.

Cap is an open-source screen recording and sharing platform designed as a direct alternative to Loom, but with the key difference that you can self-host it entirely. Recording, storage, and sharing all happen on your own server.

Key Features of Cap:

  • Open-source and free, under the MIT License
  • Supports simultaneous screen and webcam recording
  • Automatically generates shareable links, no third-party uploads needed
  • Built-in AI summarization (supports integration with local LLMs)
  • Offers macOS / Windows desktop clients

This article chronicles my complete journey deploying Cap on a Synology NAS, including the 5 major pitfalls I encountered and the eventual success with local AI integration. If you’re looking to build your own screen recording platform, just follow along!

Cap open-source screen recording platform dashboard on Synology NAS

Pre-Deployment Checklist

What You’ll Need

Item Requirement
Synology NAS DSM 7.2+, 4GB+ RAM recommended
Docker Container Manager installed (search in Package Center)
SSH Access Control Panel → Terminal & SNMP → Enable SSH service
Domain + SSL Certificate For HTTPS reverse proxy (can use free Let’s Encrypt)
Nginx Proxy Manager Docker-deployed HTTPS reverse proxy tool

Why is HTTPS necessary? The browser’s screen recording API (getDisplayMedia) requires a secure context and cannot be called under HTTP. This is the most easily overlooked prerequisite for the entire deployment.

Broadband Port Considerations

Many ISPs block ports 80 and 443. If this applies to you, you’ll need to choose an alternative port (we’ll use 183 as an example in this guide). This won’t affect functionality; you’ll just need to include the port number in your access URL.


Overall Architecture at a Glance

Let’s start with the big picture to understand the relationships between components. This will save you a lot of headache during troubleshooting.

                         Internet

                   Nginx Proxy Manager
                    (Self-hosted on Synology, listening on :183)
                    ┌─────────┬─────────┐
                    │         │         │
               HTTPS:183   MinIO Internal Direct Access

               cap-web:3000

          ┌────────┼────────┬──────────┐
          ▼        ▼        ▼          ▼
       MySQL    MinIO    media-     AI-Proxy
       (named   (internal   server    (self-signed SSL)
       volume)  direct)    (FFmpeg)       │
                                  Local AI Server
                                  (llama-server)

Three Design Principles (learned the hard way after hitting several roadblocks):

  1. HTTPS for those who need it — Cap Web uses Nginx Proxy Manager (screen recording API requires a secure context), while MinIO uses internal HTTP (simpler and more reliable).
  2. Reuse images, don’t compile — Use pre-built Docker images exclusively; avoid installing Bun / Rust toolchains on the NAS.
  3. Integrate into Compose where possible — Containerize the AI proxy within Docker Compose for unified lifecycle management with Cap.

Step 1: Docker Compose Orchestration

Create a project directory on your NAS and write the docker-compose.yml file. This setup involves 6 containers:

Container Image Purpose
cap-web ghcr.io/capsoftware/cap-web:latest Next.js main application + API + authentication
cap-media-server ghcr.io/capsoftware/cap-media-server:latest FFmpeg video transcoding
cap-mysql mysql:8.0 Database
cap-minio minio/minio:latest S3-compatible object storage
cap-minio-setup minio/mc:latest Automated bucket creation (one-time task)
cap-ai-proxy nginx:alpine Transparent proxy for OpenAI API → local AI

Key docker-compose.yml Configuration

version: "3.8"

services:
  cap-web:
    image: ghcr.io/capsoftware/cap-web:latest
    container_name: cap-web
    ports:
      - "3000:3000"
    env_file: .env
    extra_hosts:
      - "api.openai.com:172.21.0.100"   # DNS hijackingAI proxy
    environment:
      NODE_TLS_REJECT_UNAUTHORIZED: "0"  # Accept self-signed certificates
    depends_on:
      cap-mysql:
        condition: service_healthy
      cap-minio:
        condition: service_healthy
    networks:
      - cap-network

  cap-media-server:
    image: ghcr.io/capsoftware/cap-media-server:latest
    container_name: cap-media-server
    env_file: .env
    networks:
      - cap-network

  cap-mysql:
    image: mysql:8.0
    container_name: cap-mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: cap
      MYSQL_USER: cap
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    volumes:
      - cap-mysql-data:/var/lib/mysql   # ⚠️ Must use a named volume!
    command: >
      --innodb-buffer-pool-size=256M
      --performance-schema=OFF
    deploy:
      resources:
        limits:
          memory: 1G
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - cap-network

  cap-minio:
    image: minio/minio:latest
    container_name: cap-minio
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
      MINIO_API_CORS_ALLOW_ORIGIN: "https://cap.yourdomain.com:183"
    volumes:
      - ./minio:/data    # MinIO is not affected by ACLs, bind mounts are fine
    command: server /data --console-address ":9001"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - cap-network

  cap-minio-setup:
    image: minio/mc:latest
    container_name: cap-minio-setup
    depends_on:
      cap-minio:
        condition: service_healthy
    entrypoint: >
      /bin/sh -c "
      mc alias set local http://cap-minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
      mc mb local/cap --ignore-existing;
      mc anonymous set download local/cap;
      "
    networks:
      - cap-network

  cap-ai-proxy:
    container_name: cap-ai-proxy
    image: nginx:alpine
    volumes:
      - ./ai-proxy.conf:/etc/nginx/conf.d/default.conf:ro
      - ./ai-certs:/etc/nginx/certs:ro
    networks:
      cap-network:
        ipv4_address: 172.21.0.100   # Static IP for DNS hijacking

volumes:
  cap-mysql-data:

networks:
  cap-network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.21.0.0/16    # Must specify a subnet to assign static IPs

Why use a named volume for MySQL instead of a bind mount? This was the first major pitfall, which we’ll detail below.


Step 2: Environment Variable Configuration

Create a .env file in your project directory:

# ====== Database ======
MYSQL_ROOT_PASSWORD=your_root_password
MYSQL_PASSWORD=your_cap_user_password

# ====== MinIO Storage ======
MINIO_ROOT_USER=your_minio_username
MINIO_ROOT_PASSWORD=your_minio_password

# ====== Cap Access URL ======
CAP_URL=https://cap.yourdomain.com:183
S3_PUBLIC_URL=http://<NAS_IP>:9000          # ⬅ Internal network direct access, no reverse proxy

# ====== AI Configuration ======
OPENAI_API_KEY=local-llama                   # Placeholder value, local AI doesn't need a real key
GROQ_API_KEY=                                # ⬅ Leave empty! Otherwise, Groq will be prioritized and fail

Regarding S3_PUBLIC_URL: This specifies your NAS’s internal IP. The browser will directly access MinIO on your internal network to upload videos. This design was crucial after overcoming the fourth pitfall, which we’ll explain later.


Step 3: HTTPS Reverse Proxy Setup

Cap Web *must* be accessed via HTTPS; otherwise, the screen recording API won’t work, and login verification codes will fail.

Configuring Nginx Proxy Manager

  1. Add a new Proxy Host in NPM.
  2. Domain: cap.yourdomain.com
  3. Scheme: https
  4. Forward Hostname: <NAS_IP>
  5. Forward Port: 3000
  6. SSL: Select your wildcard certificate (or request a Let’s Encrypt one).

If your ISP blocks port 443, don’t worry about it in NPM’s Settings → Default Host. You can simply specify your non-standard port directly in the Proxy Host configuration.

Connecting the Desktop Client

The Cap Desktop client also supports connecting to self-hosted instances. In the client settings, enter:

  • Cap Server URL: https://cap.yourdomain.com:183

5 Real-World Pitfalls and Solutions

This section contains the most valuable insights of this guide. These 5 issues were interconnected; solving one often exposed the next. If you follow the steps above, you’ll likely encounter these same challenges.

Pitfall 1: MySQL Permission Denied (Synology ACLs)

Symptom: MySQL container fails to start, with logs showing:

mysqld: Can't create/write to file '/var/lib/mysql/is_writable' (OS errno 13 - Permission denied)

Troubleshooting Process:

My first thought was permissions. I tried chown 999 and chmod 777, and ls -lad showed drwxrwxrwx+ — notice that + sign. This indicates Synology’s btrfs file system’s extended ACLs (synoacl), which intercept container write operations even beyond standard POSIX permissions.

Solution: Change MySQL’s storage from a bind mount to a Docker named volume:

# ❌ Incorrect: Bind mounts are affected by Synology ACLs
volumes:
  - ./mysql:/var/lib/mysql

# ✅ Correct: Named volumes bypass host file system ACLs
volumes:
  - cap-mysql-data:/var/lib/mysql

Named volumes are managed directly by Docker, bypassing the host’s file system ACL layer, which resolves the issue at its root.


Pitfall 2: Login Verification Code Failure

Symptom: You receive a verification code in your email, but entering it on the webpage results in a failure. Opening browser DevTools reveals that the cookie was never set.

Root Cause: NextAuth, by default, enables secure: true, meaning cookies are flagged with Secure. Browsers will only send such cookies over HTTPS connections. If you access Cap via HTTP + internal IP, the browser simply rejects the cookie.

Solution: This is why Step 3 emphasized configuring an Nginx Proxy Manager with HTTPS. Once HTTPS was properly set up, verification code login immediately worked.


Pitfall 3: Uploads Permanently Stuck

Symptom: After recording, the browser displays Uploading..., but the video never uploads. Checking cap-web logs reveals:

`x-forwarded-host` header with value `cap.yourdomain.com` does not match
`origin` header with value `cap.yourdomain.com:183` from a forwarded
Server Actions request. Aborting the action.

Root Cause: When Nginx Proxy Manager forwards to a non-standard HTTPS port (like :183), it implicitly drops the port number from the X-Forwarded-Host header. Next.js Server Actions perform strict validation between the forwarded host and origin headers, and a port mismatch causes the request to be rejected.

Troubleshooting Journey:

  1. Added proxy_set_header X-Forwarded-Host $http_host; in NPM’s Custom Nginx Configuration — ineffective, NPM’s location block overrides server-level custom configurations.
  2. Hardcoded proxy_set_header X-Forwarded-Host cap.yourdomain.com:183;still ineffective, same reason as above.

Eventual Solution: Initially, I added an intermediate Nginx container in Compose to forcefully inject the correct X-Forwarded-Host header. However, this solution was later removed after Pitfall 4 was solved, as the final architecture no longer required it.

Lesson Learned: If you’re using the standard 443 port, you’ll likely avoid this issue. This problem typically arises from the combination of a non-standard port + NPM + Next.js Server Actions.


Pitfall 4: MinIO Upload 403 Forbidden

Symptom: After resolving Pitfall 3, uploads still get stuck, but the X-Forwarded-Host error is gone from the logs. Opening the browser’s Network panel, I found that PUT requests to MinIO returned 403 Forbidden, with the response header Server: openresty — indicating NPM was responding, not MinIO itself.

Investigation Chain:

Cap generates pre-signed URL
Browser uses URL to access MinIO via NPM
NPM (openresty) modifies certain headers (e.g., Host header)
S3 signature validation fails
403 Forbidden

Root Cause: NPM, during forwarding, likely altered critical headers within the S3 pre-signed URL, causing MinIO’s signature validation to fail. S3 pre-signed URLs are highly sensitive to headers; any modification will lead to a signature mismatch.

Final Solution: MinIO bypasses NPM; direct browser access via internal IP.

S3_PUBLIC_URL=http://<NAS_IP>:9000

Simultaneously, adjust MinIO’s CORS configuration to allow access from your domain:

MINIO_API_CORS_ALLOW_ORIGIN: "https://cap.yourdomain.com:183,http://<NAS_IP>:3000"

This solution assumes the browser and NAS are on the same internal network. If your use case requires external access to videos, you’ll need to configure a separate HTTPS domain specifically for MinIO.


Pitfall 5: Orphaned Container Port Conflict

Symptom: After resolving Pitfall 4, I removed the intermediate proxy container and rebuilt the setup, only to receive an error:

Bind for 0.0.0.0:3000 failed: port is already allocated
Found orphan containers ([cap-web-nginx]) for this project.

Root Cause: Containers removed from docker-compose.yml are not automatically deleted. The orphaned cap-web-nginx container was still occupying port 3000.

Fix:

docker rm -f cap-web-nginx && docker compose up -d

One command, problem solved. But if you’re unaware of this mechanism, you could waste a significant amount of time on this error.


Pitfall Timeline Summary

Order Problem Root Cause Fix
1 MySQL Permission Denied Synology btrfs extended ACLs Switched to Docker named volume
2 Verification Code Login Failed NextAuth secure cookie requires HTTPS Configured NPM HTTPS reverse proxy
3 Uploads Stuck NPM dropped X-Forwarded-Host port Nginx intermediary to inject header
4 MinIO 403 Forbidden NPM interfered with S3 pre-signed URL validation MinIO direct browser access via internal IP
5 Port Conflict Removed container not automatically deleted docker rm -f to clean up

Local AI Integration: Automating Video Summaries

Cap includes a built-in AI summarization feature that automatically generates a text summary of your video after recording. Officially, it supports three AI Providers:

Provider Environment Variable Purpose
Groq GROQ_API_KEY LLM summarization
OpenAI OPENAI_API_KEY LLM summarization (fallback)
Deepgram DEEPGRAM_API_KEY Speech-to-text

However, there’s a catch: Cap’s source code hardcodes the OpenAI API endpoint as https://api.openai.com/v1/chat/completions and does not support an OPENAI_BASE_URL environment variable. This means you can’t directly point requests to a local large language model.

Solution Approach: DNS Hijacking + Self-Signed Certificate + Nginx Proxy

Without modifying Cap’s source code, we can redirect requests through a three-layer forwarding mechanism:

Inside cap-web container:
  fetch("https://api.openai.com/v1/chat/completions")

extra_hosts resolution (DNS hijacking)
  172.21.0.100 (cap-ai-proxy, static IP)

nginx 443 self-signed SSLforwards to local AI
  llama-server (OpenAI-compatible interface)

Local AI Environment

You’ll need a machine with a GPU to run llama.cpp‘s llama-server. It natively provides an OpenAI-compatible /v1/chat/completions endpoint and does not require an API Key.

Recommended models (for 12GB VRAM):

  • Gemma Series Coding Quantized Versions — 64K context, strong code understanding
  • Qwen Series Quantized + Multimodal Versions — 32K context, supports image understanding

Example startup command:

llama-server -m your-model.gguf --port 8080 --host 0.0.0.0

AI Proxy Nginx Configuration

Create ai-proxy.conf:

server {
    listen 443 ssl;
    server_name api.openai.com;

    ssl_certificate     /etc/nginx/certs/api.openai.com.crt;
    ssl_certificate_key /etc/nginx/certs/api.openai.com.key;

    proxy_read_timeout 180s;       # llama-server can be slow, allow sufficient timeout
    proxy_send_timeout 180s;

    location / {
        proxy_pass http://<AI_SERVER_IP>:8080;
        proxy_http_version 1.1;
        proxy_buffering off;       # Supports SSE streaming responses
        proxy_cache off;
    }
}

Generating Self-Signed Certificates

mkdir -p ai-certs && cd ai-certs
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
  -keyout api.openai.com.key \
  -out api.openai.com.crt \
  -subj "/CN=api.openai.com"

Verifying Connectivity

# Test from within the cap-web container to see if it can reach the local AI via proxy
docker exec cap-web wget -qO- https://api.openai.com/v1/models --no-check-certificate
# Expected return: JSON list of models from llama-server

If you see a JSON list of models, the entire chain is working! Now, after recording a video, Cap will automatically call your local large language model to generate a summary — with zero API fees and all data remaining within your internal network.


Quick Reference for Daily Operations

Common Commands

cd /volume2/docker/cap

# View all container statuses
docker compose ps

# View logs
docker compose logs --tail=50 cap-web
docker compose logs --tail=50 cap-media-server

# Search for login links (find in logs if email is not configured)
docker logs cap-web 2>&1 | grep -i "signin\|callback\|token"

# Check AI connectivity
docker exec cap-web wget -qO- https://api.openai.com/v1/models --no-check-certificate

# Update images
docker compose pull && docker compose up -d

# Restart a single service
docker compose restart cap-web

Data Backup

# MySQL automated backup (can be added to Synology Task Scheduler)
docker exec cap-mysql mysqldump -u cap -p<password> cap > /volume2/backup/cap_$(date +%Y%m%d).sql

# MinIO video files
# Simply back up the ./minio directory

Disk Usage Check

du -sh /volume2/docker/cap/*
docker system df     # View overall Docker disk usage

Frequently Asked Questions (FAQ)

Q1: What are the advantages and disadvantages of Cap compared to Loom?

Honestly, Cap’s feature maturity isn’t quite on par with Loom yet. Its video editing capabilities are limited, and mobile support is minimal. However, its advantages are clear: your data is entirely in your control, there are no recording duration limits, and you can connect local large language models for summarization, costing you zero API fees. If privacy and cost are your priorities, Cap is worth a try. If you need an out-of-the-box, seamless team collaboration experience, Loom is currently less hassle.

Q2: How much RAM does Cap deployment require?

A Synology NAS with at least 4GB of RAM is recommended. MySQL is capped at 1GB, Cap Web and Media Server each require several hundred MBs, plus MinIO and system overhead, making 4GB the bare minimum. If you’re running AI models concurrently, a dedicated AI server is advisable.

Q3: Does MinIO *have* to be accessed internally? How can I access videos externally?

In the current setup, MinIO is accessed internally to avoid NPM interfering with S3 signatures. If external video access is required, you can configure a separate HTTPS domain for MinIO (bypassing NPM, using MinIO’s built-in TLS or an independent reverse proxy).

Q4: Can I use cloud AI APIs instead of local AI?

Yes, absolutely. Simply fill in your real OPENAI_API_KEY or GROQ_API_KEY in the .env file. No AI proxy configuration is needed then. If you provide both Groq and OpenAI keys, Cap will prioritize Groq.

Q5: What are the implications of using a non-standard port (e.g., 183)?

The primary impact is on the X-Forwarded-Host header. If you use the standard 443 port, Pitfall 3’s issue won’t occur. Non-standard ports require correct port information handling at the reverse proxy layer, as Next.js Server Actions have strict validation for this.

Q6: How do I configure email sending so I don’t have to search logs for login links?

Cap supports the Resend email service. Simply configure RESEND_API_KEY and your sender email address in the .env file. Refer to Cap’s official documentation for specific parameters.


Final Thoughts

The entire deployment process took me about two days, with most of the time spent on troubleshooting. Pitfalls 3 and 4 were the most exhausting — both involved how reverse proxies modify request headers, and these types of issues are incredibly difficult to diagnose directly from logs, requiring browser DevTools to compare request headers for pinpointing the cause.

If you’re planning to deploy Cap, my recommendations are:

  1. Start with the standard 443 port first; get it working before bothering with non-standard ports.
  2. Have MinIO accessed directly via the internal network from the get-go; this avoids Pitfall 4 entirely.
  3. Always use Docker named volumes for MySQL; steer clear of Synology ACL headaches.
  4. Local AI integration is a nice-to-have; focus on getting the core functionality up and running first.

I hope this tutorial helps you avoid some of the detours I took. If you encounter other issues during deployment, feel free to leave a comment and let’s discuss!


This article is based on practical deployment experience with the Cap open-source project. Please attribute when reproducing.

Leave a Comment