# Ahmed Elywa — Full Site Content > Full Stack Engineer building tools for the Prisma & GraphQL ecosystem. Creator of PalJS with 2.3M+ npm downloads and 700+ GitHub stars. Building Homeschool Hub serving 80,000+ users. ## About Ahmed Elywa is a Full Stack Engineer from Egypt with 15+ years of experience. He started programming in 2011 building Tatar War, a strategy browser game that grew popular across the Arabic-speaking world. It was written in PHP, served thousands of concurrent players, and taught him everything about databases, server management, and building products people actually use. When the mobile era made browser games obsolete, he pivoted to modern web development. He taught himself JavaScript, TypeScript, React, and Node.js through Udemy courses and relentless building. By 2020, he had created PalJS — an open-source toolkit for the Prisma and GraphQL ecosystem that now has over 2.3 million npm downloads and 700+ GitHub stars. PalJS caught the attention of Prisma, who brought him on as a contract engineer to work on their Nexus integration. He also worked with Spantree Technology Group on GraphQL performance optimization and plugin development. Since 2019, he has been the lead engineer at Your Creative People, where he built Homeschool Hub from the ground up. It serves over 80,000 homeschooling families with features like an Activities Builder for creating student assignments. The platform runs on Next.js, GraphQL, Prisma, and PostgreSQL, deployed on Kubernetes. ### Languages Spoken - Arabic: Native or bilingual proficiency - English: Professional working proficiency ## Projects ### Homeschool Hub (80,000+ users) Full-featured online homeschool platform with an Activities Builder for creating student assignments. Serves a large and growing user base of homeschooling families. Tech: TypeScript, Next.js, React, GraphQL, Prisma, PostgreSQL, Kubernetes Links: live: https://homeschoolhub.com, appStore: https://apps.apple.com/eg/app/bju-press-homeschool-hub-lite/id6741116206, playStore: https://play.google.com/store/apps/details?id=com.homeschoolhub.hub_lite ### PrayCalendar (517K+ calendars · 12K+ users) Prayer times calendar subscription service used across 10+ countries. Generates synced calendar links for Google, Apple, and Outlook with automatic updates. Features a widget generator, Home Assistant integration, and a public API. Tech: TypeScript, Next.js, React, Tailwind CSS Links: github: https://github.com/AhmedElywa/prayCalendar, live: https://pray.ahmedelywa.com ### Gold Price Tracker Real-time gold price tracking application with historical charts and price alerts. Tech: TypeScript, Next.js, React, Tailwind CSS Links: github: https://github.com/AhmedElywa/gold-price-app, live: https://gold.ahmedelywa.com ### Knights War Browser-based strategy game rebuilt from the ground up with a modern stack. Bun-native backend with TanStack Start frontend in a monorepo architecture. Tech: TypeScript, Bun, TanStack Start, PostgreSQL, Prisma ### PalJS / prisma-tools (700+ stars · 2.3M+ downloads) Open-source toolkit for the Prisma ecosystem. Includes a GraphQL CRUD generator, admin UI components, and a CLI to scaffold full-stack projects in minutes. Tech: TypeScript, Node.js, React, GraphQL, Prisma Links: github: https://github.com/paljs/prisma-tools, npm: https://www.npmjs.com/org/paljs, live: https://paljs.com ## Experience ### Full Stack Engineer at Your Creative People (Full-time) Sep 2019 — Present · 7 yrs 1 mos · Remote - Building an online home school platform (https://homeschoolhub.com) - It has attracted more than 80 000 users in the year since its launch - Working on the Activities Builder used to create the assignments questions for students Skills: TypeScript, Node.js, React.js, JavaScript, GraphQL, Tailwind CSS, PostgreSQL, Prisma, DigitalOcean, Jira, Kubernetes, GitLab CI/CD ### Software Engineer at Prisma (Contract) Apr 2022 — Sep 2022 · 5 mos · Remote Working on fixing bugs and adding new features to the nexus integration for Prisma. Skills: Typescript, Node.js, JavaScript ### Co-Founder at Pal.Js (Part-time) Jan 2020 — Present · 6 yrs 9 mos · Tanta, Egypt Working on open-source tools to help developers start projects fast and without writing tedious code - CLI to start Node.js project in only 2 minutes - GraphQL CRUD system generator for Prisma and various GraphQL libraries - Prisma Tables to start your admin panel - React UI components compatible with the generated API Skills: TypeScript, Node.js, React.js, JavaScript, GraphQL, Prisma ### Full Stack Engineer at 3rb Hero (Freelance) Feb 2021 — Jun 2022 · 1 yrs 4 mos · Remote Worked on moving a popular browser game from PHP to a modern web development stack Skills: TypeScript, Node.js, React.js, JavaScript, GraphQL, Tailwind CSS, PostgreSQL, Prisma, DigitalOcean ### Software Engineer at Spantree Technology Group, LLC (Contract) Jul 2020 — May 2021 · 10 mos · Remote - Worked on GraphQL nexus plugins (input validation, apollo federation integration) - Improve GraphQL queries performance - Fixed production bugs Skills: TypeScript, Node.js, JavaScript, GraphQL, Prisma ### Co-Founder at Tatar War (Self-employed) Jan 2011 — Aug 2018 · 7 yrs 7 mos · Tanta, Egypt Strategy Browser Game. It was trendy before the mobile 3d games, and in 2018 there was no one using it. It didn't have a responsive design for mobile. Skills: PHP, HTML, CSS, MYSQL ## Blog Posts ### Claude Code Workflows Part 1: Visual Debugging on a Headless Server Date: 2026-03-09 · 8 min read Tags: claude-code, playwright, debugging, remote-development When you develop on a remote headless server, there's no browser to open. You can't just `Cmd+Tab` to Chrome and check how your component looks. I needed a way to visually debug UI changes directly from Claude Code — take a screenshot, see it instantly, and iterate. Here's the system I built: Claude Code takes screenshots with Playwright MCP, uploads them to a self-hosted image service, and gives me shareable links I can open on any device. Screenshots auto-delete after 24 hours because they're throwaway by nature. ## The Architecture ```mermaid flowchart TD A["Claude Code"] -->|"1. Navigate to page"| B["Dev Server\nlocalhost:3000\n(Headless Chromium)"] A -->|"2. Take screenshot"| C["screenshot.png"] C -->|"3. imgup upload"| D["img.myapp.com\nNginx + SSL\n24h auto-cleanup"] D -->|"4. Returns shareable URL"| A ``` ## Step 1: Set Up the Image Service First, we need somewhere to host screenshots. I run a simple Nginx file server on a subdomain that serves static images over HTTPS. ### Create the Directory ```bash sudo mkdir -p /var/www/img.myapp.com sudo chown $USER:$USER /var/www/img.myapp.com ``` ### Configure Nginx ```bash sudo tee /etc/nginx/sites-available/img.myapp.com << 'EOF' server { server_name img.myapp.com; root /var/www/img.myapp.com; index index.html; location / { try_files $uri $uri/ =404; add_header Cache-Control "public, max-age=3600"; } # Disable directory listing autoindex off; # Only serve image files location ~* \.(png|jpg|jpeg|gif|webp|svg|ico)$ { expires 1d; add_header Cache-Control "public, immutable"; } listen 80; } EOF sudo ln -s /etc/nginx/sites-available/img.myapp.com /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx ``` Key security details: - **`autoindex off`** — prevents anyone from browsing the directory listing - **Image-only location block** — only serves known image file types - **Cache headers** — images are cached for 1 day since they're temporary anyway ### Add SSL ```bash sudo certbot --nginx -d img.myapp.com ``` ### Create the imgup Command Add this function to your `~/.zshrc`: ```bash imgup() { if [ -z "$1" ]; then echo "Usage: imgup " return 1 fi local file="$1" local name="$(date +%s)_$(basename "$file")" cp "$file" "/var/www/img.myapp.com/$name" echo "https://img.myapp.com/$name" } ``` Now you can upload any image: ```bash imgup screenshot.png # https://img.myapp.com/1741520400_screenshot.png ``` The timestamp prefix prevents filename collisions. The URL is immediately shareable — open it on your phone, send it to a colleague, or paste it in a PR. ### Auto-Delete After 24 Hours Screenshots are throwaway. Set up a cron job to clean them up automatically: ```bash crontab -e ``` Add this line: ``` 0 * * * * find /var/www/img.myapp.com -type f -mmin +1440 -delete ``` This runs every hour and deletes any file older than 24 hours (1440 minutes). Your image directory stays clean without manual intervention. ## Step 2: Enable Playwright MCP in Claude Code Claude Code has a Playwright MCP plugin that controls a headless Chromium browser. It can navigate to pages, click elements, fill forms, and — crucially — take screenshots. ### Install Chromium Playwright needs a browser to control. Install Chromium and its dependencies: ```bash npx playwright install chromium ``` On a headless Ubuntu server, you might also need system dependencies: ```bash npx playwright install-deps chromium ``` ### Enable the Plugin In Claude Code, the Playwright plugin is available as a built-in MCP server. Enable it in your `~/.claude/settings.json`: ```json { "enabledPlugins": { "playwright@claude-plugins-official": true } } ``` Restart Claude Code after enabling the plugin. ### Auto-Approve Playwright Tools To avoid being asked for permission every time Claude Code takes a screenshot, add the Playwright MCP tools to your allowed permissions in `~/.claude/settings.local.json`: ```json { "permissions": { "allow": [ "mcp__plugin_playwright_playwright__browser_navigate", "mcp__plugin_playwright_playwright__browser_snapshot", "mcp__plugin_playwright_playwright__browser_take_screenshot", "mcp__plugin_playwright_playwright__browser_resize", "mcp__plugin_playwright_playwright__browser_wait_for", "mcp__plugin_playwright_playwright__browser_click", "mcp__plugin_playwright_playwright__browser_type" ] } } ``` ## Step 3: Create the Screenshot Skill The manual workflow is: navigate to a page, resize the viewport, take a screenshot, run imgup, report the URL. That's 5 steps every time. A Claude Code skill automates this into a single command. ### What Are Skills? Skills are markdown files that teach Claude Code reusable workflows. When you say a trigger phrase, Claude Code loads the skill and follows its instructions. They live in `~/.claude/skills/`. ### Create the Skill ```bash mkdir -p ~/.claude/skills/imgup-screenshot ``` Create `~/.claude/skills/imgup-screenshot/SKILL.md`: ```markdown --- name: imgup-screenshot description: Take screenshots of webpages and upload them for remote preview. Trigger phrases include "take screenshot", "preview the page", "show me how it looks", or "imgup". --- # imgup-screenshot Take screenshots of webpages and upload them for remote preview on headless servers. ## Workflow ### 1. Navigate to the Target Page Use Playwright MCP to navigate to the URL: mcp__plugin_playwright_playwright__browser_navigate(url: "http://localhost:3000") Wait for the page to load if needed: mcp__plugin_playwright_playwright__browser_wait_for(time: 2) ### 2. Take Desktop Screenshot (1280x720) Resize the browser to desktop viewport: mcp__plugin_playwright_playwright__browser_resize(width: 1280, height: 720) Take the screenshot: mcp__plugin_playwright_playwright__browser_take_screenshot(type: "png", filename: "desktop.png") ### 3. Take Mobile Screenshot (375x667) Resize the browser to mobile viewport: mcp__plugin_playwright_playwright__browser_resize(width: 375, height: 667) Take the screenshot: mcp__plugin_playwright_playwright__browser_take_screenshot(type: "png", filename: "mobile.png") ### 4. Upload Screenshots Upload each screenshot using the imgup command: imgup desktop.png imgup mobile.png Each command returns a URL like https://img.myapp.com/1234567890_desktop.png ### 5. Present Results Report both URLs to the user: Desktop (1280x720): https://img.myapp.com/...desktop.png Mobile (375x667): https://img.myapp.com/...mobile.png ## Viewport Reference | Device | Width | Height | |---------|-------|--------| | Mobile | 375 | 667 | | Desktop | 1280 | 720 | ## Notes - Screenshots are saved to the current working directory - The imgup command copies files to the local server (auto-deleted after 24 hours) - Full-page screenshots can be taken with fullPage: true parameter ``` ### Tell Claude Code About imgup Add a note to your `~/.claude/CLAUDE.md` so Claude Code knows about the tool even without the skill: ```markdown ### imgup - Image Upload CLI Upload images to local server and get shareable links: imgup /path/to/image.png # Returns: https://img.myapp.com/1234567890_image.png Files are served from /var/www/img.myapp.com/ and auto-deleted after 24 hours. Use with Playwright screenshots to share with user for review. ``` ## Using It in Practice Once everything is set up, the workflow is natural. Here's how a typical visual debugging session goes: ### Quick Preview ``` You: take a screenshot of the homepage Claude Code: → Navigates to http://localhost:3000 → Takes desktop screenshot (1280x720) → Takes mobile screenshot (375x667) → Uploads both with imgup → Returns: Desktop: https://img.myapp.com/1741520400_desktop.png Mobile: https://img.myapp.com/1741520401_mobile.png ``` Open the links on your phone, tablet, or any browser. No port forwarding needed for a quick visual check. ### Iterative Design This is where it gets powerful. You can have Claude Code make changes and immediately verify them: ``` You: the navbar text is too small on mobile, make it bigger Claude Code: → Edits the component → Takes a new mobile screenshot → Uploads it → "Here's the updated mobile view: https://img.myapp.com/..." → You check the link → "looks good but the padding is off on the right" → Fixes padding, takes another screenshot → Repeat until it looks right ``` You never leave the terminal. Claude Code sees the page through Playwright, makes changes, and shows you the result — all in one conversation. ### Comparing Before and After When reviewing a PR or making visual changes, you can ask for comparison screenshots: ``` You: take a screenshot of /blog before and after your changes Claude Code: → Stashes changes, screenshots the original → Restores changes, screenshots the updated version → Uploads both → "Before: https://img.myapp.com/...before.png" → "After: https://img.myapp.com/...after.png" ``` ### Checking Specific Elements Playwright MCP can do more than full-page screenshots. You can interact with the page: ``` You: open the mobile menu and screenshot it Claude Code: → Resizes to mobile viewport → Clicks the hamburger menu button → Waits for animation → Takes screenshot → Uploads it ``` ## Why Not Just Use Port Forwarding? Port forwarding (via Termius or SSH) works great for live development — you see changes in real-time in your browser. But screenshots fill a different role: - **Shareable** — send a link to anyone, no VPN or SSH access needed - **Persistent** — the screenshot exists for 24 hours, even after you stop the dev server - **CI/CD friendly** — you can add screenshot steps to your GitHub Actions workflow - **Reviewable** — paste screenshot links directly in PR descriptions - **No setup on the viewing device** — any browser on any device can open the link I use port forwarding for active development and screenshots for reviewing, sharing, and documenting changes. ## What's Next In the [next post](/blog/claude-code-workflows-part-2-skills-and-automation), we'll dive deeper into Claude Code skills — how to build them, the ones I use daily, and how they turn repetitive workflows into single commands. ## Series Navigation 1. **Visual Debugging on a Headless Server** (you are here) 2. [Skills & Automation](/blog/claude-code-workflows-part-2-skills-and-automation) (coming soon) --- ### Remote Dev Server Guide Part 5: Production Deployment with GitHub Actions & PM2 Date: 2026-03-09 · 9 min read Tags: hetzner, github-actions, pm2, nginx, deployment, devops In [Part 4](/blog/hetzner-remote-dev-part-4-claude-code-dev-workflow), we set up Claude Code and our development workflow. Now let's close the loop: when you push to `main`, your app automatically builds, deploys to the server, and restarts — all through Tailscale's secure network. ## Architecture Overview ```mermaid flowchart LR A["git push\nto main"] --> B["GitHub\nRepository"] B --> C["GitHub Actions\nRunner"] C -->|"1. Build (bun)\n2. Join Tailscale\n3. rsync to server\n4. Restart PM2"| D["Hetzner Server"] D --- E["Nginx (443)\n↓ reverse proxy\nPM2 → Node.js (3003)"] ``` The critical part: the GitHub Actions runner joins your Tailscale network to SSH into the server. **No SSH port is exposed to the public internet.** The CI runner authenticates through Tailscale, just like your personal devices. ## Step 1: Configure Nginx Create a site configuration for your app: ```bash sudo tee /etc/nginx/sites-available/myapp.com << 'EOF' server { server_name myapp.com www.myapp.com; location / { proxy_pass http://127.0.0.1:3003; 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; } listen 80; } EOF sudo ln -s /etc/nginx/sites-available/myapp.com /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx ``` Key details: - **`proxy_pass`** points to `127.0.0.1:3003` — the app only listens locally - **`Upgrade` headers** support WebSocket connections (needed for Next.js HMR if you ever run dev mode) - **`X-Forwarded-Proto`** ensures your app knows it's behind HTTPS ## Step 2: Set Up SSL with Let's Encrypt Install Certbot and get a free SSL certificate: ```bash sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d myapp.com -d www.myapp.com ``` Certbot automatically: - Obtains the certificate - Modifies your Nginx config to use HTTPS - Sets up HTTP → HTTPS redirect - Configures automatic renewal via systemd timer Verify auto-renewal works: ```bash sudo certbot renew --dry-run ``` ## Step 3: Configure PM2 [PM2](https://pm2.keymetrics.io) is a production process manager for Node.js. It handles restarts, log management, and process monitoring. Since we installed Node.js system-wide via NodeSource in Part 2, the deploy user already has access to it. Just install PM2: ```bash sudo npm install -g pm2 ``` Create an ecosystem configuration at `/var/www/ecosystem.config.js`: ```javascript module.exports = { apps: [ { name: "myapp.com", script: "server.js", cwd: "/var/www/myapp.com", env: { PORT: 3003, HOSTNAME: "127.0.0.1" }, }, // Add more apps as needed { name: "other-app", script: "server.js", cwd: "/var/www/other-app", env: { PORT: 3004, HOSTNAME: "127.0.0.1" }, }, ], }; ``` Every app binds to `127.0.0.1` — Nginx handles public-facing traffic. Start your apps: ```bash sudo -u deploy pm2 start /var/www/ecosystem.config.js sudo -u deploy pm2 save sudo -u deploy pm2 startup ``` `pm2 save` persists the process list, and `pm2 startup` creates a systemd service so PM2 starts automatically on server reboot. ## Step 4: Next.js Standalone Build For deployment, Next.js should build in **standalone mode**. This bundles only the necessary dependencies, resulting in a ~50 MB deployment instead of a 500+ MB `node_modules` folder. In your `next.config.ts`: ```typescript import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'standalone', }; export default nextConfig; ``` The standalone build outputs to `.next/standalone/` with its own `server.js` entry point. You need to copy static files separately: ```bash bun run build cp -r .next/static .next/standalone/.next/static cp -r public .next/standalone/public ``` ## Step 5: Generate Deploy Keys Before setting up CI/CD, create an SSH key pair specifically for deployments: ```bash # On your local machine ssh-keygen -t ed25519 -f deploy_key -C "github-actions-deploy" # Add the public key to the deploy user's authorized_keys on the server # (the "hetzner" alias already includes port 1993 from your SSH config) ssh hetzner "sudo -u deploy mkdir -p /home/deploy/.ssh && \ sudo -u deploy tee -a /home/deploy/.ssh/authorized_keys" < deploy_key.pub # You'll add the private key to GitHub Secrets in the next step cat deploy_key ``` ## Step 6: Set Up Tailscale for GitHub Actions This is the secret sauce. Instead of exposing SSH to the internet for CI/CD, we make the GitHub Actions runner join your Tailscale network. ### Create a Tailscale OAuth Client 1. Go to [Tailscale Admin Console](https://login.tailscale.com/admin/settings/oauth) → Settings → OAuth Clients 2. Create a new OAuth client with the `Devices: Write` scope 3. Save the **Client ID** and **Client Secret** ### Create an ACL Tag In your Tailscale ACL policy, add a tag for CI runners: ```json { "tagOwners": { "tag:ci": ["autogroup:admin"] }, "acls": [ { "action": "accept", "src": ["tag:ci"], "dst": ["tag:server:*"] } ] } ``` ### Add GitHub Secrets In your GitHub repository, go to Settings → Secrets and add: | Secret | Value | |--------|-------| | `TS_OAUTH_CLIENT_ID` | Your Tailscale OAuth client ID | | `TS_OAUTH_SECRET` | Your Tailscale OAuth secret | | `DEPLOY_KEY` | Private key from Step 5 | | `DEPLOY_HOST` | Your server's Tailscale IP | | `DEPLOY_USER` | `deploy` | | `DEPLOY_PATH` | `/var/www/myapp.com` | ## Step 7: Create the GitHub Actions Workflow Create `.github/workflows/deploy.yml`: ```yaml name: Deploy to Hetzner on: push: branches: [main] workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install --frozen-lockfile - name: Build run: bun run build - name: Prepare standalone output run: | cp -r .next/static .next/standalone/.next/static cp -r public .next/standalone/public - name: Setup Tailscale uses: tailscale/github-action@v3 with: oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} tags: tag:ci - name: Setup SSH run: | mkdir -p ~/.ssh echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key ssh-keyscan -p 1993 -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts - name: Deploy standalone to server run: | rsync -avz --delete --exclude='.env' \ -e "ssh -i ~/.ssh/deploy_key -p 1993" \ .next/standalone/ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/ - name: Restart PM2 run: | ssh -i ~/.ssh/deploy_key -p 1993 ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "\ cd ${{ secrets.DEPLOY_PATH }} && \ pm2 delete myapp.com 2>/dev/null || true && \ PORT=3003 HOSTNAME=127.0.0.1 pm2 start server.js --name myapp.com" ``` Let's walk through each step: 1. **Build** — Bun installs dependencies and builds the Next.js app in standalone mode 2. **Prepare** — copies static assets into the standalone output 3. **Tailscale** — the CI runner joins your Tailscale network using OAuth, tagged as `tag:ci` 4. **SSH** — sets up SSH credentials to connect to your server's Tailscale IP on port 1993 5. **Deploy** — rsync copies the standalone build to `/var/www/myapp.com/`, deleting old files (but preserving `.env`) 6. **Restart** — kills the old PM2 process and starts a new one with the updated code The entire deploy takes about 60-90 seconds for a typical Next.js app. ## Environment Variables Production apps often need API keys, database URLs, and other secrets. PM2 supports environment variables in the ecosystem config, but for sensitive values, use a `.env` file on the server: ```bash # Create an env file for your app (as deploy user) sudo -u deploy tee /var/www/myapp.com/.env << 'EOF' DATABASE_URL=postgresql://user:pass@127.0.0.1:5432/myapp API_SECRET=your-secret-here EOF sudo -u deploy chmod 600 /var/www/myapp.com/.env ``` Next.js in standalone mode automatically loads `.env` files. For other frameworks, you can load them in the PM2 ecosystem config: ```javascript module.exports = { apps: [{ name: "myapp.com", script: "server.js", cwd: "/var/www/myapp.com", env: { PORT: 3003, HOSTNAME: "127.0.0.1" }, // Next.js loads .env from cwd automatically }], }; ``` **Important**: Never commit `.env` files to Git. Add them to `.gitignore` and manage them directly on the server. ## Managing Multiple Apps The PM2 ecosystem file makes it easy to run multiple apps on one server: ```javascript module.exports = { apps: [ { name: "ahmedelywa.com", script: "server.js", cwd: "/var/www/ahmedelywa.com", env: { PORT: 3003, HOSTNAME: "127.0.0.1" }, }, { name: "ek-website", script: "server.js", cwd: "/var/www/ek-website", env: { PORT: 3005, HOSTNAME: "127.0.0.1" }, }, { name: "gold-price-app", script: "server.js", cwd: "/var/www/gold-price-app", env: { PORT: 3002, HOSTNAME: "127.0.0.1" }, }, ], }; ``` Each app gets its own Nginx config, SSL certificate, and PM2 process. I currently run 5 production apps on a single server with this setup. ## Monitoring PM2 provides built-in monitoring: ```bash # View all processes pm2 list # View logs pm2 logs myapp.com # Monitor CPU/memory in real-time pm2 monit # View detailed process info pm2 show myapp.com ``` For a quick health check from anywhere: ```bash ssh hetzner "sudo -u deploy pm2 list" ``` ## The Complete Flow Here's what happens when you push code: 1. You push to `main` on GitHub 2. GitHub Actions triggers the deploy workflow 3. The runner builds your app with Bun 4. The runner joins your Tailscale network 5. It SSH's into your server via the Tailscale IP on port 1993 6. rsync copies the new build to `/var/www/` 7. PM2 restarts the app 8. Nginx serves the new version immediately Zero downtime in practice — PM2 restarts are sub-second, and Nginx buffers requests during the transition. ## Series Wrap-Up Over this 5-part series, we've built a complete remote development and deployment platform: 1. [**Chose a Hetzner server**](/blog/hetzner-remote-dev-part-1-choosing-your-server) with the right specs for development workloads 2. [**Set up Ubuntu**](/blog/hetzner-remote-dev-part-2-initial-server-setup) with dev tools, Docker, and a clean directory structure 3. [**Secured everything**](/blog/hetzner-remote-dev-part-3-security-with-tailscale) with Tailscale VPN — SSH invisible to the internet 4. [**Installed Claude Code**](/blog/hetzner-remote-dev-part-4-claude-code-dev-workflow) and built a tmux-based development workflow 5. [**Automated deployment**](/blog/hetzner-remote-dev-part-5-production-deployment) with GitHub Actions deploying through Tailscale The total cost: about $40-60/month for a Hetzner dedicated server that handles development, CI/CD, and production hosting for multiple apps. Compare that to separate services for each concern and it's a significant saving. More importantly, you can work from anywhere. Open your laptop at a coffee shop, SSH into the server, attach to tmux, and you're exactly where you left off. No syncing, no rebuilding, no lost context. ## Series Navigation 1. [Choosing Your Server](/blog/hetzner-remote-dev-part-1-choosing-your-server) 2. [Initial Server Setup](/blog/hetzner-remote-dev-part-2-initial-server-setup) 3. [Security with Tailscale VPN](/blog/hetzner-remote-dev-part-3-security-with-tailscale) 4. [Claude Code & Development Workflow](/blog/hetzner-remote-dev-part-4-claude-code-dev-workflow) 5. **Production Deployment with GitHub Actions & PM2** (you are here) --- ### Remote Dev Server Guide Part 4: Claude Code & Development Workflow Date: 2026-03-08 · 13 min read Tags: claude-code, ai, development, hetzner, remote-development In [Part 3](/blog/hetzner-remote-dev-part-3-security-with-tailscale), we locked down our server with Tailscale. Now let's set up the development workflow that makes this setup genuinely better than working locally. ## Why Claude Code on a Remote Server? Claude Code is Anthropic's CLI tool for AI-assisted development. It runs in your terminal, reads your codebase, edits files, runs commands, and helps you build features. Running it on a remote server has unique advantages: - **Persistent sessions** — Claude Code keeps its context in tmux sessions that survive disconnects - **Powerful hardware** — your 8-core server handles builds and AI processing faster than most laptops - **Work from anywhere** — SSH in from your Mac, iPad, or phone and pick up where you left off - **No battery drain** — heavy computation happens on the server, not your laptop ## Install Claude Code Claude Code offers a standalone installer that downloads a self-contained binary — no npm required: ```bash curl -fsSL https://claude.ai/install.sh | sh ``` This installs the `claude` binary to `~/.local/bin/`. Make sure it's in your PATH (the installer usually handles this). Then authenticate with your Anthropic account: ```bash claude # Follow the authentication prompts ``` Claude Code stores its configuration in `~/.claude/`: ``` ~/.claude/ ├── CLAUDE.md # Global instructions for all projects ├── settings.json # Preferences ├── .credentials.json # Auth tokens └── projects/ # Per-project memory and history ``` ## Configure CLAUDE.md The `~/.claude/CLAUDE.md` file gives Claude Code persistent context about your environment. This is crucial for a remote server because Claude needs to know things like "don't start dev servers in the background" and "use tmux for long-running processes." Here's a trimmed version of what I use: ```markdown # System Context ## Environment - **Host**: Hetzner dedicated server - **OS**: Ubuntu 24.04 LTS - **Working directory**: /home/dev ## Hardware - **CPU**: AMD Ryzen 7 3700X — 8 cores / 16 threads - **RAM**: 64 GB ## IMPORTANT: Dev Server Process Management **NEVER launch dev servers using background processes.** Background processes get detached and become zombies. **Correct approach:** - Always start dev servers inside tmux using `tmux send-keys` - Example: `tmux send-keys -t dev "bun run dev" Enter` ## Projects ### Development (dev user, /home/dev/projects/) - **my-app** — Next.js app. Dev port: 3000 ### Production (deploy user, /var/www/) - **my-app** — Production port: 3003 ``` This prevents Claude Code from accidentally creating zombie processes (a real problem I hit early on) and gives it awareness of your project layout. ## Configure settings.json Claude Code's behavior is controlled through `~/.claude/settings.json`. Here's my configuration: ```json { "env": { "MAX_THINKING_TOKENS": "63000", "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "16384", "BASH_DEFAULT_TIMEOUT_MS": "600000" }, "permissions": { "allow": [ "Bash(git:*)", "Bash(bun:*)", "Read(./**)", "Edit(./**)" ], "deny": [ "Read(./secrets/**)", "Read(./**/credentials*)" ], "defaultMode": "acceptEdits" }, "alwaysThinkingEnabled": true, "effortLevel": "high" } ``` What each setting does: - **`MAX_THINKING_TOKENS`** — gives Claude more space to reason through complex problems - **`CLAUDE_CODE_MAX_OUTPUT_TOKENS`** — allows longer code generation responses - **`BASH_DEFAULT_TIMEOUT_MS`** — 10-minute timeout for slow builds (default is too short for large Next.js builds) - **`permissions.allow`** — auto-approve Git commands, Bun commands, and file reads/edits so Claude doesn't ask for permission on every action - **`permissions.deny`** — prevent reading sensitive files like secrets and credentials - **`defaultMode: "acceptEdits"`** — automatically accept file edits (you can review them in Git) - **`alwaysThinkingEnabled`** — enables extended thinking for better reasoning - **`effortLevel: "high"`** — Claude puts more effort into responses ## Custom Status Line Claude Code supports a custom status line that shows useful information at the bottom of the terminal. I use a script that displays the current directory, Git branch with diff stats, model name, thinking mode, version, and context usage percentage. Create `~/.claude/statusline-command.sh`: ```bash #!/bin/bash RESET="\033[0m" CYAN="\033[36m" RED="\033[31m" GREEN="\033[32m" YELLOW="\033[33m" MAGENTA="\033[35m" ORANGE="\033[38;5;208m" DIM="\033[2m" # Read JSON input from Claude Code input=$(cat) # Extract values cwd=$(echo "$input" | jq -r '.workspace.current_dir // ""') dir=$(basename "$cwd") model_name=$(echo "$input" | jq -r '.model.display_name // "Claude"') version=$(echo "$input" | jq -r '.version // "unknown"') # Check if thinking is enabled settings_file="$HOME/.claude/settings.json" if [ -f "$settings_file" ]; then thinking_enabled=$(jq -r '.alwaysThinkingEnabled // false' "$settings_file") else thinking_enabled="false" fi if [ "$thinking_enabled" = "true" ]; then thinking_indicator="🧠" else thinking_indicator="💭" fi # Context usage percentage usage=$(echo "$input" | jq '.context_window.current_usage') if [ "$usage" != "null" ]; then current=$(echo "$usage" | jq '.input_tokens + .cache_creation_input_tokens + .cache_read_input_tokens') size=$(echo "$input" | jq '.context_window.context_window_size') ctx=$((current * 100 / size)) else ctx=0 fi # Git info if git -c core.fileMode=false rev-parse --git-dir > /dev/null 2>&1; then branch=$(git -c core.fileMode=false branch --show-current 2>/dev/null || echo "?") if [ ${#branch} -gt 15 ]; then branch="${branch:0:12}..." fi stats=$(git -c core.fileMode=false diff --shortstat 2>/dev/null) if [ -n "$stats" ]; then adds=$(echo "$stats" | grep -o '[0-9]* insertion' | grep -o '[0-9]*') dels=$(echo "$stats" | grep -o '[0-9]* deletion' | grep -o '[0-9]*') adds=${adds:-0} dels=${dels:-0} git_info=$(printf ":${RED}%s${RESET} ${GREEN}+%s${RESET}${RED}-%s${RESET}" "$branch" "$adds" "$dels") else git_info=$(printf ":${RED}%s${RESET}" "$branch") fi else git_info="" fi # Output: dir:branch +X-Y | Model 🧠 v1.0 | ctx% printf "${CYAN}%s${RESET}%s ${DIM}|${RESET} ${MAGENTA}%s${RESET} %s ${ORANGE}v%s${RESET} ${DIM}|${RESET} ${YELLOW}%d%%${RESET}" \ "$dir" \ "$git_info" \ "$model_name" \ "$thinking_indicator" \ "$version" \ "$ctx" ``` Make it executable and add it to your settings: ```bash chmod +x ~/.claude/statusline-command.sh ``` In `~/.claude/settings.json`, add the status line configuration: ```json { "statusLine": { "type": "command", "command": "/bin/bash /home/dev/.claude/statusline-command.sh" } } ``` Claude Code pipes JSON context (workspace info, model, context window usage) to the script's stdin. The script parses it with `jq` and outputs a colored status line showing everything at a glance — which project you're in, what branch, how many lines changed, which model is active, and how much context you've used. ## The tmux + Claude Code Workflow This is the core of the workflow. Here's how I structure my tmux sessions: ### Session Layout ```bash # Create a session for your project tmux new-session -s myapp # Window 0: Claude Code (main workspace) # Window 1: Dev server # Window 2: Shell (git, tests, etc.) ``` I keep Claude Code running in Window 0. When it needs to start a dev server, it sends the command to Window 1 via `tmux send-keys`. This keeps processes visible and controllable. ### Starting a Dev Session ```bash # SSH into your server ssh hetzner # Attach to your session (or create if it doesn't exist) tmux attach -t myapp 2>/dev/null || tmux new-session -s myapp # Navigate to your project cd ~/projects/my-app # Start Claude Code claude ``` ### Disconnecting and Reconnecting The beauty of this setup: you can close your laptop, go to a coffee shop, open your laptop, and pick up exactly where you left off: ```bash ssh hetzner tmux attach -t myapp # Everything is exactly as you left it — Claude Code, dev server, terminal history ``` This works from any device. I've connected from my iPhone using Termius to check on a build that Claude Code was running. ## Project-Level CLAUDE.md Each project can have its own `CLAUDE.md` in the repo root with project-specific instructions: ```markdown # Project: My App ## Stack - Next.js 16 with App Router - Tailwind CSS v4 - Bun as package manager ## Commands - `bun run dev` — start dev server on port 3000 - `bun run build` — production build - `bun run lint` — run Biome linting ## Conventions - Use Biome for formatting (not Prettier) - Use Lefthook for git hooks - Standalone output for production builds ``` ## Code Quality with Biome and Lefthook I use [Biome](https://biomejs.dev) for linting and formatting (replacing ESLint + Prettier), and [Lefthook](https://github.com/evilmartians/lefthook) for git hooks. ### Biome Configuration ```json { "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 120, "quoteStyle": "single" }, "linter": { "enabled": true } } ``` ### Lefthook Configuration ```yaml pre-commit: commands: biome: glob: "*.{js,ts,tsx,json}" stage_fixed: true run: bun biome check --staged --write --no-errors-on-unmatched ``` This auto-formats staged files on commit. Claude Code respects these hooks — when it creates commits, Biome automatically formats the code. ## Tips for Remote Development ### 1. Use Mosh for Unstable Connections If you're on flaky WiFi, [Mosh](https://mosh.org) handles disconnects gracefully. Mosh uses UDP on high-numbered ports, which works over the Tailscale interface since we allowed all traffic on `tailscale0` in Part 3: ```bash # Install on server sudo apt install mosh # Connect (via Tailscale IP) mosh --ssh="ssh -p 1993" dev@YOUR_TAILSCALE_IP ``` ### 2. Keep Your Dev Server in tmux Never let Claude Code or any process start dev servers in the background. Always use tmux windows: ```bash # From Claude Code's perspective, it should do: tmux send-keys -t myapp:1 "bun run dev" Enter # NOT: bun run dev & # This creates zombie processes ``` ### 3. VS Code in the Browser with code-server Instead of using VS Code's Remote SSH extension, I run [code-server](https://github.com/coder/code-server) — a full VS Code instance that runs on the server and is accessible from any browser. No local VS Code installation needed. You can code from a tablet, a borrowed laptop, or any device with a browser. #### Install code-server ```bash curl -fsSL https://code-server.dev/install.sh | sh ``` This installs the `code-server` binary system-wide. Enable and start it as a systemd service for your user: ```bash sudo systemctl enable --now code-server@$USER ``` #### Configure code-server The config file is at `~/.config/code-server/config.yaml`: ```yaml bind-addr: 127.0.0.1:8080 auth: password password: your-secure-password-here cert: false ``` Key settings: - **`bind-addr: 127.0.0.1:8080`** — only listens locally, Nginx handles public access - **`auth: password`** — requires a password to access the editor - **`cert: false`** — Nginx + Certbot handles SSL, not code-server After editing the config, restart the service: ```bash sudo systemctl restart code-server@$USER ``` #### Set Up Nginx Reverse Proxy Create an Nginx config to expose code-server on a subdomain: ```bash sudo tee /etc/nginx/sites-available/code-server << 'EOF' server { server_name code.myapp.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Accept-Encoding gzip; 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; } listen 80; } EOF sudo ln -s /etc/nginx/sites-available/code-server /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx ``` The `Upgrade` and `Connection` headers are essential — code-server uses WebSockets for the editor's real-time features. #### Add SSL with Certbot ```bash sudo certbot --nginx -d code.myapp.com ``` Certbot automatically configures HTTPS and sets up HTTP → HTTPS redirects. #### Install Extensions code-server supports VS Code extensions. Install them from the command line: ```bash # Essentials code-server --install-extension biomejs.biome code-server --install-extension bradlc.vscode-tailwindcss code-server --install-extension prisma.prisma # Theme code-server --install-extension zhuangtongfa.material-theme code-server --install-extension pkief.material-icon-theme # Claude Code extension code-server --install-extension anthropic.claude-code ``` Now you can open `https://code.myapp.com` from any browser, enter your password, and you have a full VS Code editor running on your server. I use this when I'm away from my main machine — it's a complete development environment accessible from anywhere. ### 4. Connect from Any Device with Termius The whole point of a remote dev server is working from anywhere. I use [Termius](https://termius.com) as my SSH client across all my devices — Mac, iPad, and iPhone. It's the glue that makes this setup truly portable. #### Why Termius - **Cross-device sync** — set up your server connection once, and it's available on every device automatically - **Built-in port forwarding** — preview your dev server from any device without command-line SSH tunnels - **SFTP** — browse and transfer files visually when needed - **Snippets** — save common commands (like `tmux attach -t website`) and run them with a tap #### Setting Up Your Connection In Termius, create a new host: | Field | Value | |-------|-------| | Label | `hetzner` | | Hostname | Your server's Tailscale IP (`100.x.y.z`) | | Port | `1993` | | Username | `dev` | | Key | Import your SSH private key | Save it once, and it syncs to your Mac, iPad, and iPhone instantly. No re-entering credentials on each device. #### Port Forwarding for Dev Server Preview When your Next.js dev server is running on the server (port 3000), you need port forwarding to preview it in your local browser. In Termius: 1. Open your `hetzner` host settings 2. Go to **Port Forwarding** 3. Add a new rule: - **Type**: Local - **Local port**: `3000` - **Remote host**: `127.0.0.1` - **Remote port**: `3000` Now visit `http://localhost:3000` on your device and you'll see your dev server. This works on Mac, iPad, and iPhone — Termius handles the SSH tunnel transparently. You can add multiple forwarding rules for different services: | Local Port | Remote | Use | |-----------|--------|-----| | `3000` | `127.0.0.1:3000` | Next.js dev server | | `5432` | `127.0.0.1:5432` | PostgreSQL | | `8080` | `127.0.0.1:8080` | code-server | #### The Multi-Device Workflow Here's what working from different devices looks like in practice: **From my Mac** — full development. Termius connects to the server, I attach to tmux, run Claude Code, and use port forwarding to preview in Chrome. **From my iPad** — lighter work. I open Termius, attach to the existing tmux session, and everything is exactly where I left it. Claude Code is still running. The dev server is still up. I can review changes, run builds, or make quick edits. **From my iPhone** — quick checks. Tap the `hetzner` connection in Termius, `tmux attach -t website`, check if a build passed or review Claude Code's output. It's surprisingly usable for monitoring. The key insight: the server is always running. Termius is just a window into it. You're not "transferring work" between devices — you're looking at the same environment from different screens. ## Real-World Example Here's what a typical session looks like when I'm building a feature: ```bash # 1. Open Termius and connect to hetzner # (or from terminal: ssh hetzner) # 2. Attach to the project session tmux attach -t website # 3. Start Claude Code in window 0 claude # 4. Tell Claude what to build > Add a dark mode toggle to the navbar # Claude reads my codebase, understands the Tailwind setup, # creates the component, installs any needed packages, # and tests the build — all on the server. # 5. Dev server is already running in window 1 # Preview via Termius port forwarding at localhost:3000 # 6. When done, close the laptop # Everything keeps running on the server # Later, open Termius on iPad and pick up where you left off ``` ## What's Next We have a fully functional, secure development environment with AI assistance. In the [final part](/blog/hetzner-remote-dev-part-5-production-deployment), we'll set up production deployment — Nginx reverse proxy, PM2 process management, Let's Encrypt SSL, and automated deploys with GitHub Actions. ## Series Navigation 1. [Choosing Your Server](/blog/hetzner-remote-dev-part-1-choosing-your-server) 2. [Initial Server Setup](/blog/hetzner-remote-dev-part-2-initial-server-setup) 3. [Security with Tailscale VPN](/blog/hetzner-remote-dev-part-3-security-with-tailscale) 4. **Claude Code & Development Workflow** (you are here) 5. [Production Deployment with GitHub Actions & PM2](/blog/hetzner-remote-dev-part-5-production-deployment) --- ### Remote Dev Server Guide Part 3: Security with Tailscale VPN Date: 2026-03-07 · 6 min read Tags: hetzner, tailscale, security, ssh, vpn In [Part 2](/blog/hetzner-remote-dev-part-2-initial-server-setup), we set up our development environment. Right now, our server has SSH open on port 22, accepting connections from the entire internet. Even with key-based auth, this is a target for brute-force attacks, port scanners, and exploits against OpenSSH itself. The fix: make SSH **only accessible through Tailscale VPN**. If you're not on the Tailscale network, the server doesn't even respond. ## What is Tailscale? [Tailscale](https://tailscale.com) creates a private mesh VPN using WireGuard. Every device you install it on gets a stable IP in the `100.x.x.x` range. These devices can talk to each other directly, even behind NATs and firewalls, without exposing any ports to the public internet. Key benefits: - **Zero configuration networking** — no port forwarding, no firewall rules to manage - **Identity-based access** — tied to your Google/GitHub/Microsoft account - **Works everywhere** — macOS, Linux, Windows, iOS, Android - **Free for personal use** — up to 100 devices ## Install Tailscale on Your Server ```bash curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up ``` This prints a URL. Open it in your browser to authenticate with your Tailscale account. Once authenticated, your server gets a Tailscale IP: ```bash tailscale ip -4 # 100.x.y.z (your Tailscale IP — save this, you'll need it everywhere) ``` ## Install Tailscale on Your Devices Install the Tailscale app on every device you want to access the server from: - **Mac**: Download from [tailscale.com/download](https://tailscale.com/download) or `brew install --cask tailscale` - **iPhone/iPad**: Install from the App Store - **Windows**: Download from the website - **Linux**: Same curl command as the server Once all devices are connected, you can see them: ```bash tailscale status # 100.x.y.z hetzner-dev you@ linux - # 100.x.y.w macbook-pro you@ macOS active # 100.x.y.v iphone you@ iOS active ``` You can now SSH to your server's Tailscale IP from any of your devices, even from a coffee shop or airport WiFi. ## Harden SSH Configuration Now let's lock down SSH so it **only listens on the Tailscale interface** and uses a non-standard port. Create a hardened SSH config: ```bash sudo tee /etc/ssh/sshd_config.d/hardened.conf << 'EOF' # Disable password authentication PasswordAuthentication no # Disable root login with password (only key) PermitRootLogin no # Disable empty passwords PermitEmptyPasswords no # Limit authentication attempts MaxAuthTries 3 # Disable X11 forwarding X11Forwarding no # Disable TCP forwarding (unless needed) AllowTcpForwarding yes # Set idle timeout (5 minutes) ClientAliveInterval 300 ClientAliveCountMax 2 # Use strong algorithms KexAlgorithms curve25519-sha256@libssh.org,curve25519-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256 Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256 EOF ``` ### Change the SSH Port Edit the main config to listen only on Tailscale IP, on a custom port: ```bash sudo sed -i 's/^Port 22$/Port 1993/' /etc/ssh/sshd_config ``` Why port 1993? It's just an arbitrary non-standard port. Automated scanners target port 22 — using a different port eliminates most noise from your auth logs. ### Bind SSH to Tailscale Only Add a `ListenAddress` directive so SSH only accepts connections on the Tailscale interface: ```bash # Replace 100.x.y.z with YOUR Tailscale IP from `tailscale ip -4` echo "ListenAddress 100.x.y.z" | sudo tee -a /etc/ssh/sshd_config echo "ListenAddress 127.0.0.1" | sudo tee -a /etc/ssh/sshd_config ``` This is the key security measure. SSH is now **invisible** to the public internet. Even if someone scans every port on your server's public IP, they won't find SSH. Restart SSH: ```bash sudo systemctl restart sshd ``` **Important**: Before closing your current SSH session, open a new terminal and verify you can connect via Tailscale: ```bash ssh -p 1993 dev@YOUR_TAILSCALE_IP ``` If it works, you're good. If not, use the Hetzner rescue console to fix the config. ## Set Up UFW Firewall Even with SSH bound to Tailscale, a firewall adds defense-in-depth. Allow only what's needed: ```bash sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow 80/tcp # Nginx HTTP sudo ufw allow 443/tcp # Nginx HTTPS sudo ufw allow in on tailscale0 # Allow all traffic on Tailscale interface sudo ufw enable ``` The `allow in on tailscale0` rule lets SSH (and any other traffic) flow freely over the Tailscale interface, while blocking everything else from the public internet. Verify: ```bash sudo ufw status ``` ## Update Your SSH Config (Client Side) On your Mac (or any device you SSH from), add this to `~/.ssh/config`: ``` Host hetzner HostName YOUR_TAILSCALE_IP User dev Port 1993 IdentityFile ~/.ssh/id_ed25519 ``` Now you can just type: ```bash ssh hetzner ``` ## The Security Model Let's recap what we've achieved: ```mermaid flowchart TB subgraph Internet["Public Internet"] A["Port scanning → nothing found\nSSH brute force → can't connect"] end subgraph Server["Hetzner Server"] B["Nginx (80/443) → public web"] C["SSH (1993) → Tailscale only"] D["Apps (3001-3005) → localhost\nDBs (5432-5434) → localhost\nRedis (6379-6380) → localhost"] end subgraph Devices["Your Devices (Tailscale)"] E["MacBook → ssh hetzner"] F["iPhone → Termius app"] G["iPad → Termius app"] end Internet -->|"ports 80/443 only"| B Devices -->|"WireGuard tunnel"| C ``` Only your Tailscale-authenticated devices can reach SSH. The public internet can only hit Nginx on ports 80 and 443 for serving your websites. All application ports and databases are bound to `127.0.0.1`. ## Working from Your Phone This setup means you can SSH into your server from your iPhone. Apps like [Blink Shell](https://blink.sh) or Termius support SSH with Tailscale. As long as Tailscale is running on your phone, you can connect from anywhere — home, office, train, airport. I regularly check on things from my iPhone using Tailscale. The connection is fast because WireGuard is lightweight and Tailscale handles NAT traversal automatically. ## Optional: Tailscale SSH Tailscale also offers [Tailscale SSH](https://tailscale.com/tailscale-ssh), which replaces OpenSSH entirely. It authenticates using your Tailscale identity instead of SSH keys. I prefer keeping OpenSSH for compatibility, but Tailscale SSH is worth exploring if you want even simpler access management. ## What's Next The server is now secure. In [Part 4](/blog/hetzner-remote-dev-part-4-claude-code-dev-workflow), we'll install Claude Code and set up a development workflow that lets you build features with AI assistance from anywhere. ## Series Navigation 1. [Choosing Your Server](/blog/hetzner-remote-dev-part-1-choosing-your-server) 2. [Initial Server Setup](/blog/hetzner-remote-dev-part-2-initial-server-setup) 3. **Security with Tailscale VPN** (you are here) 4. [Claude Code & Development Workflow](/blog/hetzner-remote-dev-part-4-claude-code-dev-workflow) 5. [Production Deployment with GitHub Actions & PM2](/blog/hetzner-remote-dev-part-5-production-deployment) --- ### Remote Dev Server Guide Part 2: Initial Server Setup Date: 2026-03-06 · 6 min read Tags: hetzner, ubuntu, devops, server In [Part 1](/blog/hetzner-remote-dev-part-1-choosing-your-server), we picked and ordered a Hetzner dedicated server. Now we have a fresh Ubuntu 24.04 box with root access. Let's turn it into a development machine. ## Create Your Dev User Never develop as root. Create a dedicated user with sudo privileges: ```bash adduser dev usermod -aG sudo dev ``` Set up SSH key authentication for the new user. This is a prerequisite — in [Part 3](/blog/hetzner-remote-dev-part-3-security-with-tailscale) we'll disable password authentication entirely, so keys must work first: ```bash # On your local machine, copy your public key ssh-copy-id dev@YOUR_SERVER_IP # Or manually mkdir -p /home/dev/.ssh echo "your-public-key-here" >> /home/dev/.ssh/authorized_keys chmod 700 /home/dev/.ssh chmod 600 /home/dev/.ssh/authorized_keys chown -R dev:dev /home/dev/.ssh ``` **Verify key login works** before moving on — open a new terminal and confirm `ssh dev@YOUR_SERVER_IP` connects without asking for a password. Switch to the dev user for the rest of this guide: ```bash su - dev ``` ## System Updates ```bash sudo apt update && sudo apt upgrade -y ``` Install essential build tools: ```bash sudo apt install -y build-essential curl git wget unzip htop tmux jq lsof ``` ## Install Node.js I use the [NodeSource](https://github.com/nodesource/distributions) APT repository for Node.js. It installs system-wide, so all users (including the `deploy` user) get access without extra setup: ```bash curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - sudo apt install -y nodejs ``` Verify: ```bash node --version # v24.x.x npm --version ``` ## Install Bun [Bun](https://bun.sh) is a fast JavaScript runtime and package manager. I use it for building and running Next.js apps: ```bash curl -fsSL https://bun.sh/install | bash source ~/.bashrc bun --version ``` Bun installs to `~/.bun/bin/`. It's significantly faster than npm/yarn for installing dependencies and running builds. ## Install Docker Docker is essential for running databases and services in isolation: ```bash # Add Docker's official GPG key and repository sudo apt install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin # Add your user to the docker group (no sudo needed for docker commands) sudo usermod -aG docker dev newgrp docker ``` ### Database Containers I run PostgreSQL and Redis in Docker containers for each project. Here's an example `docker-compose.yml`: ```yaml services: postgres: image: postgres:18 restart: always environment: POSTGRES_USER: dev POSTGRES_PASSWORD: dev POSTGRES_DB: myapp ports: - "127.0.0.1:5432:5432" volumes: - pgdata:/var/lib/postgresql/data redis: image: redis:alpine restart: always ports: - "127.0.0.1:6379:6379" volumes: pgdata: ``` Key detail: bind to `127.0.0.1` so databases are only accessible locally, not from the internet. ## Install PostgreSQL Client You'll want the `psql` client for connecting to your database containers: ```bash sudo apt install -y postgresql-client-16 ``` ## Set Up tmux tmux keeps your sessions alive when you disconnect. This is critical for a remote dev server — you can close your laptop, come back later, and everything is exactly where you left it. Create a basic `.tmux.conf`: ```bash cat >> ~/.tmux.conf << 'EOF' # Better prefix set -g prefix C-a unbind C-b bind C-a send-prefix # Mouse support set -g mouse on # Start window numbering at 1 set -g base-index 1 setw -g pane-base-index 1 # Increase scrollback set -g history-limit 50000 # Better colors set -g default-terminal "screen-256color" set -ga terminal-overrides ",xterm-256color:Tc" EOF ``` Start a named session for your work: ```bash tmux new-session -s dev ``` Inside tmux, you can create windows for different tasks: - **Window 0**: Shell / Git operations - **Window 1**: Dev server (`bun run dev`) - **Window 2**: Database / Docker logs - **Window 3**: Testing Detach with `Ctrl+A, D`. Reattach with `tmux attach -t dev`. ## Configure Git ```bash git config --global user.name "Your Name" git config --global user.email "your@email.com" git config --global init.defaultBranch main # Generate SSH key for GitHub ssh-keygen -t ed25519 -C "your@email.com" cat ~/.ssh/id_ed25519.pub # Add this to GitHub → Settings → SSH Keys ``` ## Set Up Zsh with Oh My Zsh I use [Oh My Zsh](https://ohmyz.sh) for a better shell experience — autocompletion, Git integration, and a clean prompt: ```bash # Install zsh sudo apt install -y zsh # Set zsh as default shell chsh -s $(which zsh) # Install Oh My Zsh sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" ``` I use the `robbyrussell` theme (the default) and the `git` plugin. In `~/.zshrc`: ```bash ZSH_THEME="robbyrussell" plugins=(git) source $ZSH/oh-my-zsh.sh ``` The `git` plugin gives you useful aliases like `gst` (git status), `ga` (git add), `gc` (git commit), and tab completion for branch names. ### Shell Aliases I add custom aliases to `~/.zshrc` for common operations: ```bash # Claude Code shortcuts alias cc='claude --dangerously-skip-permissions' alias ccw='claude --dangerously-skip-permissions --worktree' # tmux session management alias tn='tmux new-session -s' # tn myapp alias tl='tmux list-sessions' # list all sessions alias ta='tmux attach-session -t' # ta myapp alias tk='tmux kill-session -t' # tk myapp ``` The `cc` alias launches Claude Code in auto-accept mode — useful when you trust it to make file edits without confirmation prompts. The `ccw` variant does the same but in a Git worktree for isolated work. ## Create a Deploy User For production apps, use a separate user that only runs PM2 processes: ```bash sudo adduser deploy sudo mkdir -p /var/www sudo chown deploy:deploy /var/www ``` This separation means your dev user's environment is isolated from production. Even if something goes wrong in development, your production apps keep running. ## Install Nginx Nginx acts as a reverse proxy in front of your Node.js apps: ```bash sudo apt install -y nginx sudo systemctl enable nginx ``` We'll configure Nginx for specific apps in [Part 5](/blog/hetzner-remote-dev-part-5-production-deployment). ## Directory Structure Here's how I organize things: ``` /home/dev/ ├── projects/ # All development repos │ ├── ahmedelywa.com/ │ ├── my-other-app/ │ └── ... ├── .claude/ # Claude Code config └── .ssh/ # SSH keys /var/www/ # Production deployments (deploy user) ├── ahmedelywa.com/ ├── my-other-app/ └── ecosystem.config.js # PM2 config ``` Development happens in `/home/dev/projects/`. Production deployments go to `/var/www/` under the `deploy` user. Clean separation. ## What's Next The server is now ready for development work. But it's currently accessible via password authentication on the default SSH port — a huge security risk. In [Part 3](/blog/hetzner-remote-dev-part-3-security-with-tailscale), we'll lock it down with Tailscale VPN so it's only accessible through your private network. ## Series Navigation 1. [Choosing Your Server](/blog/hetzner-remote-dev-part-1-choosing-your-server) 2. **Initial Server Setup** (you are here) 3. [Security with Tailscale VPN](/blog/hetzner-remote-dev-part-3-security-with-tailscale) 4. [Claude Code & Development Workflow](/blog/hetzner-remote-dev-part-4-claude-code-dev-workflow) 5. [Production Deployment with GitHub Actions & PM2](/blog/hetzner-remote-dev-part-5-production-deployment) --- ### Remote Dev Server Guide Part 1: Choosing a Hetzner Dedicated Server Date: 2026-03-05 · 4 min read Tags: hetzner, devops, remote-development, server Most developers work on their local machine. I did too — stuck on my MacBook Pro as my only option. If I wasn't at my desk with my laptop, I couldn't code. I wanted the freedom to work from anywhere: open my iPad at a coffee shop, pull out my phone to check a build, or SSH in from a borrowed laptop. That meant my development environment couldn't live on any single device. The solution: a Hetzner dedicated server that acts as my permanent development machine. I connect to it from my Mac, iPad, and iPhone using [Termius](https://termius.com) — one environment, accessible from any device. In this series, I'll walk through the entire setup — from picking the hardware to deploying production apps with GitHub Actions. ## Why Dedicated Over VPS? Cloud VPS instances (DigitalOcean, AWS EC2, Linode) share physical hardware with other tenants. You get a slice of CPU and RAM that can fluctuate based on your neighbors. For a dev server running Docker containers, multiple Node.js processes, PostgreSQL, and Claude Code simultaneously, I wanted **predictable, bare-metal performance**. Hetzner's dedicated servers give you: - **Dedicated hardware** — no noisy neighbors, consistent performance - **Unmetered bandwidth** — 1 Gbps included, no data transfer caps - **European data centers** — great latency if you're in Europe/Middle East - **Competitive pricing** — significantly cheaper than equivalent AWS/GCP instances ## How to Pick Your Server Head to the [Hetzner Server Auction](https://www.hetzner.com/sb) for the best deals on pre-configured servers. These are previously-used machines returned by other customers, often available at significant discounts. New servers are available on the [main configurator](https://www.hetzner.com/dedicated-rootserver) if you want specific specs. ### CPU: Cores Matter More Than Clock Speed For development workloads (compiling TypeScript, running tests, building Next.js apps, running AI tools), you want **at least 8 cores**. Here's why: - `bun run build` for a Next.js app uses multiple cores for parallel page compilation - Docker runs multiple containers simultaneously - Claude Code + your dev servers + database all compete for CPU time I went with the **AMD Ryzen 7 3700X** — 8 cores, 16 threads at 3.6 GHz. It handles everything I throw at it without breaking a sweat. The load average typically sits around 2 even with 5 concurrent SSH sessions. ### RAM: 32 GB Minimum, 64 GB Ideal Here's what my server's memory looks like on a typical day: - PostgreSQL instances (3 databases): ~2 GB - Redis (2 instances): ~500 MB - Node.js dev servers: ~1-2 GB each - Docker overhead: ~1 GB - Claude Code: ~500 MB - OS + buffers/cache: ~4 GB That's already 10+ GB before I open a second project. With 64 GB, I never think about memory. If budget is tight, 32 GB works but you'll feel it when running multiple projects. ### Disk: NVMe SSD, 500 GB+ Development workloads are I/O heavy — `node_modules`, Docker layers, Git operations, database writes. A spinning HDD will make everything feel sluggish. Get an NVMe SSD. I have a 98 GB root partition (LVM) which is tight. If I were ordering again, I'd go for at least 500 GB. Docker images and `node_modules` folders add up fast. ### My Spec Sheet | Component | Spec | |-----------|------| | CPU | AMD Ryzen 7 3700X (8c/16t @ 3.6 GHz) | | RAM | 64 GB DDR4 | | Disk | NVMe SSD (LVM) | | OS | Ubuntu 24.04 LTS | | Location | Finland (Hetzner) | | Bandwidth | 1 Gbps unmetered | ## The Ordering Process 1. **Create a Hetzner account** at [hetzner.com](https://www.hetzner.com). You'll need to verify your identity (ID + address proof). 2. **Browse the Server Auction** or configure a new server. Filter by CPU generation and RAM. Sort by price. 3. **Select your OS** — choose Ubuntu 24.04 LTS. It's the most stable option with the longest support window (until 2034). 4. **Choose a data center** — pick whichever is geographically closest to you for lowest latency. I chose Finland since it provides good connectivity to the Middle East and Europe. 5. **Complete the order** — Hetzner provisions dedicated servers within minutes to a few hours. You'll receive root credentials via email. ## First Boot Once provisioned, you'll get an email with: - Server IP address - Root password (change this immediately) - Rescue system credentials SSH in for the first time: ```bash ssh root@YOUR_SERVER_IP ``` Change the root password: ```bash passwd ``` That's it for Part 1. In the [next post](/blog/hetzner-remote-dev-part-2-initial-server-setup), we'll set up users, install essential tools, configure the shell, and get the server ready for development work. ## Series Navigation 1. **Choosing Your Server** (you are here) 2. [Initial Server Setup](/blog/hetzner-remote-dev-part-2-initial-server-setup) 3. [Security with Tailscale VPN](/blog/hetzner-remote-dev-part-3-security-with-tailscale) 4. [Claude Code & Development Workflow](/blog/hetzner-remote-dev-part-4-claude-code-dev-workflow) 5. [Production Deployment with GitHub Actions & PM2](/blog/hetzner-remote-dev-part-5-production-deployment) --- ### Improve Your GraphQL Performance with Prisma Date: 2020-05-15 · 5 min read Tags: graphql, prisma, performance, typescript If you've built a GraphQL API backed by Prisma, you've probably run into two common performance pitfalls: **the N+1 problem** and **over-fetching**. In this post, I'll walk through both issues and show you how I solved them with [PrismaSelect](https://paljs.com/plugins/select), a tool I built as part of the PalJS ecosystem. ## The Problem ### N+1 Queries Consider a simple schema with `User` and `Post` models. When a client queries for users and their posts, a naive resolver hits the database once for all users, then once _per user_ to fetch their posts. For 100 users, that's 101 queries. ```graphql query { users { id name posts { title published } } } ``` A typical resolver might look like this: ```typescript const resolvers = { Query: { users: () => prisma.user.findMany(), }, User: { posts: (parent) => prisma.post.findMany({ where: { authorId: parent.id } }), }, }; ``` Every time a `User` is resolved, an additional query fires for `posts`. This scales terribly. ### Over-Fetching Even when you use Prisma's `include` to eagerly load relations, you often fetch far more columns than the client actually requested. If the client only asks for `name` and `title`, Prisma still returns every column on both tables by default. ```typescript // Fetches ALL columns on user and post, even if the client only wants two fields const users = await prisma.user.findMany({ include: { posts: true }, }); ``` This wastes bandwidth, memory, and database I/O. ## The Solution: PrismaSelect PrismaSelect bridges GraphQL and Prisma by analyzing the incoming GraphQL query's `info` object (the fourth argument to every resolver) and generating a precise `select` object that tells Prisma exactly which fields and relations to fetch. ### Installation ```bash npm install @paljs/plugins ``` ### Basic Usage Wrap your Prisma call with `PrismaSelect` and pass the GraphQL `info` object: ```typescript import { PrismaSelect } from '@paljs/plugins'; const resolvers = { Query: { users: (_parent, _args, _ctx, info) => { const select = new PrismaSelect(info).value; return prisma.user.findMany({ ...select }); }, }, }; ``` That's it. PrismaSelect inspects the query AST and produces an object like: ```json { "select": { "id": true, "name": true, "posts": { "select": { "title": true, "published": true } } } } ``` Prisma then generates a single SQL query with a JOIN (or a minimal set of queries) that fetches only the requested data. No N+1, no over-fetching. ## How It Works Under the hood, PrismaSelect walks the GraphQL `info.fieldNodes` tree recursively. For each field, it checks whether it maps to a scalar column or a relation in your Prisma schema. Relations become nested `select` or `include` objects; scalars become `true` entries in the select map. The key insight is that GraphQL already knows exactly what the client wants. We just need to translate that knowledge into Prisma's query language. ### Handling Computed Fields If your GraphQL type has fields that don't exist in the database (computed fields), PrismaSelect won't include them in the select object by default, which is the correct behavior. But you can also make sure that the underlying database columns needed for computation are always included: ```typescript const select = new PrismaSelect(info, { defaultFields: { User: { id: true, firstName: true, lastName: true, // needed for computed `fullName` field }, }, }).value; ``` ### Working with Any GraphQL Server PrismaSelect works with any GraphQL server that gives you the standard `GraphQLResolveInfo` object. That includes Apollo Server, Mercurius, GraphQL Yoga, and Nexus-based setups. ```typescript // Apollo Server example const server = new ApolloServer({ typeDefs, resolvers: { Query: { users: (_parent, args, ctx, info) => { const select = new PrismaSelect(info).value; return ctx.prisma.user.findMany({ where: args.where, orderBy: args.orderBy, ...select, }); }, }, }, }); ``` ## Before and After Here is a concrete comparison. Without PrismaSelect, fetching a list of users with their posts and comments generates dozens of queries: ```typescript // Before: N+1 queries, all columns fetched // Query 1: SELECT * FROM "User" // Query 2..N+1: SELECT * FROM "Post" WHERE "authorId" = ? // Query N+2..M: SELECT * FROM "Comment" WHERE "postId" = ? const users = await prisma.user.findMany(); // Then resolve posts and comments in separate resolvers... ``` With PrismaSelect, it collapses to one or two optimized queries: ```typescript // After: Single query with precise column selection // SELECT "User"."id", "User"."name", // "Post"."title", "Post"."published", // "Comment"."text" // FROM "User" // LEFT JOIN "Post" ON ... // LEFT JOIN "Comment" ON ... const select = new PrismaSelect(info).value; const users = await prisma.user.findMany({ ...select }); ``` The performance difference can be dramatic. In a project I worked on, switching to PrismaSelect reduced query count by over 90% and cut API response times in half for deeply nested queries. ## Conclusion The combination of GraphQL's precise query language and Prisma's flexible query builder is powerful, but only if you connect them properly. PrismaSelect does that work for you automatically. If you're building a GraphQL API with Prisma, give [PalJS](https://paljs.com) a try. The `@paljs/plugins` package includes PrismaSelect along with other utilities for building full-stack Prisma applications. ## Uses (Development Setup) ### Hardware - **MacBook Pro M1 Max**: 64 GB RAM. Primary local machine for browsing, communication, and SSH into the dev server. - **iPhone**: Mobile device for on-the-go server access via Tailscale and Termius. - **43" Monitor + 27" Monitor**: Dual monitor setup. 43-inch as primary, 27-inch as secondary. - **Hetzner Dedicated Server**: AMD Ryzen 7 3700X, 64 GB RAM, NVMe SSD. Hosts all production and development environments. - **Ubuntu 24.04 LTS**: Server OS for all workloads. Stable and well-supported. ### Editor & Terminal - **Claude Code**: Primary AI coding tool. Handles most development work directly from the terminal. - **VS Code**: Editor with Biome for linting and formatting. - **Tmux**: Terminal multiplexer for managing persistent sessions on the server. - **Zsh**: Shell of choice with sensible defaults. ### Development - **TypeScript**: Primary language for all projects. Strict mode everywhere. - **Bun**: Fast JavaScript runtime and package manager. Used for new projects. - **Next.js**: React framework for all web applications. App Router with server components. - **React**: UI library for web and mobile (React Native) applications. - **TanStack**: TanStack Start, Router, and Query for modern full-stack apps. - **Tailwind CSS**: Utility-first CSS framework. v4 with the new CSS-native engine. - **Prisma**: ORM for PostgreSQL. Type-safe database access with great DX. - **GraphQL**: API query language used across most projects with code generation. - **PostgreSQL**: Primary database for all production applications. - **Biome**: Fast linter and formatter replacing ESLint and Prettier. ### DevOps - **Docker**: Containerization for development and production services. - **Kubernetes**: Container orchestration for the Homeschool Hub platform. - **GitHub Actions**: CI/CD for building, testing, and deploying via SSH over Tailscale. - **PM2**: Process manager for Node.js production apps on the server. - **Tailscale**: Zero-config VPN for secure server access and CI/CD deployment. - **Lefthook**: Fast Git hooks manager for running checks before commits. ## Contact - Website: https://ahmedelywa.com - GitHub: https://github.com/AhmedElywa - LinkedIn: https://www.linkedin.com/in/ahmedelywa/ - X/Twitter: https://twitter.com/AhmedElywh - Email: a@ahmedelywa.com