Deploying FlowKunda on Linux: Root Pitfalls, Security Fixes, and a Complete Setup Guide
FlowKunda is a multi-agent AI orchestrator that wraps Claude Code CLI into a web UI with real-time streaming, worker management, and Telegram integration. It was built and tested on macOS — deploying it on a Linux VPS revealed a chain of issues, all stemming from one fundamental problem: running as root.
This post documents every issue we hit, the fixes, and provides a complete deployment guide so you don’t have to debug the same things we did.
The Issues: What Broke and Why#
Issue 1: Claude CLI Refuses --dangerously-skip-permissions as Root#
The Error:
Error: Claude exited with code 1: --dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons
Why it happens: Claude Code CLI has a built-in safety check that blocks --dangerously-skip-permissions when running as root or via sudo. This is intentional — running an AI agent with unrestricted file/shell access as root is a terrible idea. On macOS, most users run as a non-root user with admin privileges, so this never surfaces. On a Linux VPS, especially with PM2, everything typically runs as root.
The Fix: Create a dedicated non-root user and run FlowKunda under that user:
/usr/sbin/useradd -m -s /bin/bash flowuser
chown -R flowuser:flowuser /path/to/flowkunda
Then launch via PM2 with --uid:
pm2 start npm --name flowkunda --uid flowuser -- run serve
Issue 2: --output-format=stream-json Requires --verbose#
The Error:
Error: Claude exited with code 1: Error: When using --print, --output-format=stream-json requires --verbose
Why it happens: FlowKunda uses -p (print/non-interactive mode) with --output-format stream-json for real-time streaming. Newer versions of Claude CLI (2.1.39+ on Linux) enforce that stream-json output in print mode requires the --verbose flag. This worked on macOS with the same version — a platform-specific behavioral difference.
The Fix: Add --verbose to the Claude CLI args in both spawn locations:
In src/service/session-manager.ts:
const args = [
"-p",
message.content,
"--verbose", // <-- Add this
"--output-format",
"stream-json",
"--dangerously-skip-permissions",
// ...
];
In src/workers/claude-worker.ts:
const args: string[] = [
"-p", prompt,
"--verbose", // <-- Add this
"--output-format", "stream-json"
];
Rebuild after the change:
npm run build:server
Issue 3: Nested Claude Session Detection#
The Error:
Error: Claude Code cannot be launched inside another Claude Code session. Nested sessions share runtime resources and will crash all active sessions. To bypass this check, unset the CLAUDECODE environment variable.
Why it happens: If your VPS also runs OpenClaw (or any other Claude Code wrapper), the CLAUDECODE environment variable gets set in the parent shell. When PM2 spawns FlowKunda, the child process inherits this env var, and any Claude CLI subprocess thinks it’s being nested inside another Claude session.
The Fix: Strip CLAUDECODE from the environment when spawning Claude CLI. In both session-manager.ts and claude-worker.ts, change:
// Before
const child = spawn(config.binaries.claude, args, {
cwd,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
// After
const child = spawn(config.binaries.claude, args, {
cwd,
env: { ...process.env, CLAUDECODE: undefined },
stdio: ["ignore", "pipe", "pipe"],
});
Setting CLAUDECODE: undefined removes it from the spread object, so the child process never sees it.
Issue 4: OAuth Token Expiry#
The Error:
OAuth token has expired. Please obtain a new token or refresh your existing token.
Why it happens: Claude CLI uses OAuth tokens stored in ~/.claude/.credentials.json. These tokens expire (typically every few days). On macOS, you’re interactively logged in and can easily refresh. On a headless Linux server, there’s no browser to complete the OAuth flow.
The Fix: SSH into the server and run claude login as the flowuser:
su - flowuser -c "claude login"
This gives you a URL to open in your browser. Complete the auth flow, and the token gets saved to /home/flowuser/.claude/.credentials.json.
Pro tip: If you’ve already authenticated as root, you can copy the token:
cp /root/.claude/.credentials.json /home/flowuser/.claude/.credentials.json
chown flowuser:flowuser /home/flowuser/.claude/.credentials.json
Just remember — it’ll expire at the same time as the root token.
Issue 5: Port Binding — Public Exposure#
The Problem: By default, the Express server listens on 0.0.0.0:3456, meaning anyone on the internet can access it directly by IP, bypassing your nginx reverse proxy and any authentication (mTLS, basic auth, etc.).
The Fix: Block the port externally with iptables:
/usr/sbin/iptables -A INPUT -p tcp --dport 3456 ! -s 127.0.0.1 -j DROP
/usr/sbin/iptables-save > /etc/iptables.rules
This ensures only localhost (nginx) can reach the app. All external traffic must go through your reverse proxy.
Complete Setup Guide: FlowKunda on Linux#
Here’s the full, tested procedure from zero to production.
Prerequisites#
- Ubuntu 22.04+ VPS with root access
- Node.js 18+ installed
- Nginx installed
- A domain pointed to your VPS
- Claude CLI v2.1.42+ installed (
npm install -g @anthropic-ai/claude-code)
Step 1: Create a Dedicated User#
/usr/sbin/useradd -m -s /bin/bash flowuser
Step 2: Clone and Build#
cd /home/flowuser
git clone https://github.com/YOUR_USER/flowkunda.git
cd flowkunda
# Install dependencies
npm install
cd client && npm install && cd ..
# Build client and server
npm run build
# Set ownership
chown -R flowuser:flowuser /home/flowuser/flowkunda
Step 3: Configure Environment#
cp .env.example .env
Edit .env as needed. The defaults are fine for most setups.
Step 4: Set Up Claude CLI for flowuser#
# Make Claude CLI accessible
cp $(which claude) /usr/local/bin/claude
# Login as flowuser
su - flowuser -c "claude login"
# Follow the URL in your browser to authenticate
Step 5: Apply Code Fixes#
Add --verbose to Claude CLI args (see Issue 2 above) and strip CLAUDECODE env (see Issue 3 above) in both:
src/service/session-manager.tssrc/workers/claude-worker.ts
Then rebuild:
cd /home/flowuser/flowkunda
npm run build:server
chown -R flowuser:flowuser .
Step 6: Create PM2 Ecosystem File#
// ecosystem.config.cjs
module.exports = {
apps: [{
name: "flowkunda",
script: "npm",
args: "run serve",
cwd: "/home/flowuser/flowkunda",
uid: "flowuser",
env: {
NODE_ENV: "production",
CLAUDECODE: ""
}
}]
};
Step 7: DNS Record#
Add an A record pointing your subdomain to your VPS IP. If using mTLS, set it to DNS-only (no Cloudflare proxy):
# Example with Cloudflare API
curl -X POST "https://api.cloudflare.com/client/v4/zones/YOUR_ZONE_ID/dns_records" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"type":"A","name":"flow","content":"YOUR_VPS_IP","ttl":1,"proxied":false}'
Step 8: SSL Certificate#
# Create a temporary nginx config for the cert challenge
cat > /etc/nginx/sites-available/flow.example.com.tmp <<'EOF'
server {
listen 80;
server_name flow.example.com;
root /var/www/html;
location /.well-known/acme-challenge/ { root /var/www/html; }
}
EOF
ln -sf /etc/nginx/sites-available/flow.example.com.tmp /etc/nginx/sites-enabled/flow.example.com
nginx -t && nginx -s reload
# Get the cert
certbot certonly --webroot -w /var/www/html -d flow.example.com --non-interactive --agree-tos
Step 9: Nginx with mTLS#
server {
listen 443 ssl;
server_name flow.example.com;
ssl_certificate /etc/letsencrypt/live/flow.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/flow.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# mTLS - require client certificate
ssl_client_certificate /etc/nginx/mtls/ca.crt;
ssl_verify_client on;
location / {
proxy_pass http://127.0.0.1:3456;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
}
}
server {
listen 80;
server_name flow.example.com;
return 301 https://$host$request_uri;
}
Replace the temp config and reload:
ln -sf /etc/nginx/sites-available/flow.example.com /etc/nginx/sites-enabled/flow.example.com
nginx -t && nginx -s reload
Note: If you don’t need mTLS, remove the
ssl_client_certificateandssl_verify_clientlines. But for an AI agent with shell access, mTLS is strongly recommended.
Step 10: Firewall the Port#
/usr/sbin/iptables -A INPUT -p tcp --dport 3456 ! -s 127.0.0.1 -j DROP
/usr/sbin/iptables-save > /etc/iptables.rules
Step 11: Start and Save#
pm2 start /home/flowuser/flowkunda/ecosystem.config.cjs
pm2 save
Verify:
pm2 list | grep flowkunda
curl -s http://127.0.0.1:3456/ | head -5 # Should return HTML
Summary of macOS vs Linux Differences#
| Issue | macOS | Linux (root) |
|---|---|---|
--dangerously-skip-permissions |
Works (non-root user) | Blocked as root |
stream-json + -p |
Works without --verbose |
Requires --verbose |
CLAUDECODE env |
Not set (standalone) | Inherited from parent |
| OAuth login | Browser available | Headless — manual SSH required |
| Port binding | Local dev, not exposed | Publicly exposed by default |
Key Takeaways#
- Never run AI agents as root. Create a dedicated user. It’s not just about Claude’s safety check — it’s basic Linux hygiene.
- Always bind app ports to localhost or firewall them. Your reverse proxy is your security boundary.
- mTLS is essential for anything that gives an AI agent shell access over the web.
- Test on your target platform. macOS and Linux behave differently in subtle ways that only surface in production.
- OAuth tokens expire. Plan for token refresh on headless servers, or better yet, switch to API key auth.
Built on a Hetzner VPS running Ubuntu 24.04, Node.js 22, and way too many PM2 processes.