Skip to content

Deployment & Platform Targets

TLDR

Where your app runs shapes how you build it. For simple frontends, Netlify or Vercel offer free, painless deployment. For backends, Docker Desktop keeps your local machine clean and Hetzner gives you affordable cloud hosting. For mobile, Capacitor wraps your web app for the app stores — plan for this from the start, not after. For desktop, Tauri is the modern lightweight choice, Electron is the established heavyweight. Whatever the target, always maintain dev/prod separation and never let AI overwrite production environment variables.

Deploy Verification

This chapter covers where and how to deploy. For verifying that your code actually reached production (the failure mode that bites hardest), see Deploy Verification.


Local Development with Docker

Install Docker Desktop on your system. This gives Cline an easy way to run backend services — databases, APIs, caching layers — without installing packages directly on your machine. Every backend dependency runs in a container that can be started, stopped, and rebuilt without leaving traces on your system.

Claude and Cline should aim to use Docker for any backend work. The .clinerules should specify this. When starting a project with a backend, one of the first sprint tasks should be setting up docker-compose.yml with the required services.

Always use --force-recreate for registry deploys

docker compose up -d may skip container recreation if Docker's local cache thinks the image hasn't changed. Always use docker compose up -d --force-recreate <service> when deploying from a registry. See Deploy Verification — Docker Cache Lies.

Don't forget the control panel — even for the simplest backends, having a local interface to visualise backend processes is invaluable. See The Project Control Panel for the full pattern. The control panel is usually simpler to develop as an independent local app separate from the main project — just a GUI to interact with and understand the backend, data, automations, API calls, and deployment state.


Simple Frontend Deployment: Netlify and Vercel

For a frontend app or landing page with no backend, Netlify or Vercel offer the fastest path to a live URL. Both have generous free tiers suitable for prototypes and small production apps.

Claude and Cline should guide the user through the process:

  1. Sign up for Netlify or Vercel (whichever has the better free tier for the project's needs)
  2. Connect the GitHub repo — both platforms can watch a repo and auto-deploy on push
  3. Configure build settings — framework preset (SvelteKit, Next.js, etc.), build command, output directory
  4. Custom domain (optional) — purchase or connect a domain, configure DNS with the platform's nameservers

Stick with the easiest, most non-technical-user-friendly route by default. Both platforms handle SSL, CDN, and preview deployments automatically. For most prototypes, the default .netlify.app or .vercel.app subdomain is fine to start.


Production Hosting: Hetzner and Dev/Prod Separation

For real production applications with backends, Hetzner VPS or similar affordable cloud hosting is cost-effective and gives you full control. But once you're deploying to a server, dev/prod separation becomes critical.

Image Tag Strategy

When using a container registry (GHCR, Docker Hub, ECR), your docker-compose.yml references images by tag:

yaml
image: ghcr.io/your-org/backend:${IMAGE_TAG:-latest}

The tag you choose determines what code gets deployed:

TagWhen UpdatedUse CaseRisk
:latestOnly on main branch buildsStable release channelIf you develop on dev, :latest is always stale
:devEvery push to dev branchActive developmentSafe if your CI is reliable
:sha-abc1234Every build (immutable)Rollbacks, pinningMost precise — use for rollback

The most common mistake: Your CI builds on dev but your production .env has IMAGE_TAG=latest. Every deploy pulls the months-old :latest image. Everything looks green. Old code runs.

The fix: Set your production environment to pull the tag that your CI actually pushes:

bash
# .env.production
IMAGE_TAG_BACKEND=dev
IMAGE_TAG_DASHBOARD=dev

For rollbacks, pin a specific SHA: IMAGE_TAG_BACKEND=sha-abc1234.

See Deploy Verification — The Nine Failure Modes for the complete list of ways this goes wrong.

The rule: Cline is allowed to push to the dev server. The user must manually promote dev to prod — either through the control panel, a GitHub merge flow, or a conscious deployment step. This prevents Cline from accidentally breaking production.

The .env problem: This is a real and recurring issue. Cline uses SSH to push new environment variable additions to the production server. But development and production often use separate API keys, database URLs, and configuration values. Cline has been known to overwrite the cloud .env with local values that were out of date and only used for testing. This can be catastrophic.

Rules to bake into .clinerules:

  • Never overwrite the production .env file directly. When new env vars are needed in production, add them to .env.example with clear documentation, and notify the user to manually update the production .env.
  • Maintain separate .env.development and .env.production files (or equivalent for your framework). Cline should know which environment it's targeting at all times.
  • Before any deployment action, Cline must confirm with the user which environment is the target.

The Non-Polling Deploy Pattern

Real cost: $30 in one night

The OpsNest deploy disaster — a polling loop on unstable WiFi during a Hetzner deploy — burned around $30 in a single overnight session. Cline kept polling the server for build status; each poll was a full API request carrying the entire conversation forward; the WiFi kept dropping; the loop never converged. None of the spend produced useful work. The pattern below would have prevented every cent of it.

The catastrophic failure mode for AI deploys is polling a long-running operation. Builds, deploys, migrations, and image pulls all take minutes — sometimes tens of minutes. If AI is asked to "wait until the deploy finishes," it tends to:

  1. Run the deploy command
  2. Run a status-check command
  3. Wait, run the status-check again
  4. Wait, run the status-check again
  5. Continue forever, paying for the full conversation context on every poll

If the human goes to bed mid-deploy, this is exactly when the disaster happens. The fix is structural: AI never polls long-running operations. Ever.

The Pattern

Three rules, jointly sufficient to prevent the failure:

  1. Build on the server, not on AI's machine. SSH in, kick off git pull && npm ci && npm run build && pm2 restart, and exit. The build runs on the server's terminal independently. AI doesn't watch it.
  2. Return immediately after kicking off the operation. AI's job ends the moment the deploy command is sent. Whether the build succeeds or fails is no longer AI's concern in this turn.
  3. Hand the human a one-line status command. Something the human can run in 5 seconds when they want to know how it went. For pm2: pm2 logs <app-name> --lines 50. For Docker: docker logs <container> --tail 100. For a health endpoint: curl https://your-domain/api/health.

Concrete Example: Hetzner SvelteKit Deploy

Before (the bad pattern):

You: "Deploy the latest main to production."

AI: ssh prod "cd /app && git pull && npm run build && pm2 restart app"
[2 minutes pass]
AI: ssh prod "pm2 logs app --lines 20"
AI: "Still building. Let me check again."
AI: ssh prod "pm2 logs app --lines 20"
[10 minutes pass, network drops, AI retries...]
[$15 in spend, deploy may or may not have succeeded]

After (the non-polling pattern):

You: "Deploy the latest main to production."

AI: ssh prod "cd /app && git pull && npm ci && npm run build && pm2 restart app"
AI: "Deploy kicked off on prod. Build is running independently on the server.
     To check status:
       pm2 logs app --lines 50            # see build/runtime output
       curl https://app.example.com/api/health   # confirm it's serving
     If you want me to investigate after, paste me the output."
[Conversation ends. $0.20 spent.]

The deploy still happens. The cost stops.

Why "Build on the Server" Specifically

A common variant of the bad pattern is "build locally, then scp the artefacts up." For AI-driven deploys this is worse, not better, because:

  • The build runs on AI's machine, which means AI is watching the build (more polling)
  • Build artefacts can be tens to hundreds of megabytes — sending them over scp from a laptop on flaky WiFi is itself a long-running operation
  • The local build environment may differ from the server's (Node version, native dependencies)

Building on the server, triggered by git pull, is the SOP for almost all VPS deploys. AI's terminal output ends within seconds because the moment the SSH command returns, the AI is done. The actual build runs in the server's shell, which AI is not watching.

If you're using PM2: have a npm run deploy:server script on the server that does everything (git pull && npm ci && npm run build && pm2 restart). AI's command becomes one line: ssh prod "npm run deploy:server". Even less to go wrong.

Build-on-Server Script Template

Drop this in your repo as scripts/deploy.sh (full version in Project Templates). Make it executable. Run it on the server, never on AI's machine:

bash
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/your-app
git pull origin main
npm ci --production=false
npm run build
pm2 restart your-app --update-env
echo "✓ Deploy complete at $(date -Iseconds)"

The AI command from your laptop becomes:

bash
ssh deploy@prod 'bash /var/www/your-app/scripts/deploy.sh'

That's the entire deploy. AI's involvement ends in <30 seconds. The build runs on the server. The human checks pm2 logs later.

What About Errors?

Errors don't change the pattern. If the deploy fails, AI shouldn't be polling to find that out — the human will see it when they check the status command. AI's job for that turn is done.

The follow-up turn is a separate, clean conversation: "Here's what pm2 logs showed, please diagnose." Now AI is reading a finite log, not polling an unfinished operation. Costs are bounded.

When Polling Is OK

There are operations short enough that polling doesn't matter:

  • A test suite that takes ~30 seconds
  • A prisma migrate dev on a local DB
  • A tsc --noEmit check

For these, the natural Plan-then-Act flow handles it fine. The non-polling rule applies specifically to operations that can take more than a minute and where network flakiness can extend that arbitrarily — deploys, container builds, image pulls, large migrations, infrastructure provisioning.

Cross-References

  • Token Economics — the broader story of where token spend comes from, and the circuit breakers (max-requests-per-task, hard-stop-after-3-failures) that protect against runaway loops.
  • The Project Control Panel — health endpoints and deployment.json make "is it up?" a one-line curl, not an interactive AI investigation.

Docker Image Cleanup: The Silent Disk Killer

Every docker compose pull downloads a new image but never removes the old one. After a few weeks of regular deploys, dangling and unused images accumulate silently until the server disk fills up — and when it does, the failure is catastrophic: containers won't start, builds fail with no space for temp files, databases crash, and logs can't write. Everything was working fine, then suddenly nothing is.

This is one of the most avoidable production disasters. The fix is two lines.

Why Docker Doesn't Clean Up Automatically

When Docker pulls a new :dev image, the old image layers are no longer referenced by that tag — but they're not deleted. They become "dangling" images: present on disk, not used by any running container, invisible in normal docker ps output. A typical image is 200–800MB. After 50 deploys, you've consumed 10–40GB of disk space in images you'll never use again.

docker images won't show you the full picture. Use docker system df to see the real numbers:

bash
docker system df
# TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
# Images          47        3         18.3GB    17.9GB (97%)
# Containers      3         3         1.2MB     0B
# Local Volumes   2         2         4.1GB     0B
# Build Cache     0         0         0B        0B

That 17.9GB reclaimable is the problem. And it grows with every deploy.

The Deploy-Time Fix

Add a single docker image prune -f to the end of every registry-pull deploy sequence. This removes all dangling images immediately after the new container is running:

bash
# Complete registry-pull deploy sequence
docker compose pull backend
docker compose up -d --force-recreate backend
docker image prune -f   # remove dangling images from old pulls

-f skips the confirmation prompt, which is what you want in automated or AI-assisted deploys.

Don't use docker system prune -f here

docker system prune -f removes dangling images AND stopped containers AND unused networks. This is useful for scheduled cleanup but can remove things you still want during an active deploy session. Stick with docker image prune -f in deploy scripts.

The Cron Safety Net

Add a weekly cleanup job as a safety net. Even if the per-deploy prune is missed or disabled, this catches accumulated waste before it becomes a crisis:

bash
# /etc/cron.d/docker-cleanup
# Runs every Sunday at 3am. Removes images older than 7 days that are not in use.
0 3 * * 0 root docker system prune -f --filter "until=168h" >> /var/log/docker-prune.log 2>&1

To set it up on a Hetzner or similar VPS:

bash
# SSH into the server
ssh user@your-server

# Create the cron file
sudo nano /etc/cron.d/docker-cleanup

# Paste the entry above, save, exit

# Verify it's loaded (no output = cron picked it up)
sudo crontab -l

Monitoring Disk Space

Add a disk check to your pre-deploy checklist and to the control panel's health view:

bash
# Quick disk check before a deploy
df -h /                  # overall disk usage
docker system df         # Docker-specific breakdown

# Alert threshold: if / is above 80% used, clean up before deploying
df -h / | awk 'NR==2 {print $5}' | tr -d '%'

If disk usage is above 80%, run a full system prune before pulling new images:

bash
# Safe aggressive cleanup (only removes unused resources)
docker system prune -f --filter "until=72h"

The Checklist Addition

Add these items to your deploy checklist for any project using registry-pull deploys:

  1. Disk space check before deploydocker system df and df -h /
  2. Prune after every deploydocker image prune -f as the last step
  3. Weekly cron configured/etc/cron.d/docker-cleanup exists on the server

Bake the per-deploy prune into .clinerules:

"After every docker compose up -d --force-recreate, run docker image prune -f. Never skip this on registry-pull deploys — dangling images accumulate silently and will eventually fill the server disk."


Cache, Staleness, and "My Changes Aren't Showing"

This is the single most common frustration with SvelteKit projects deployed to a VPS. You make a change, deploy it, visit the site — and see the old version. Hard refresh fixes it. Sometimes. Log out, log in, and it reverts. The problem gets worse when deploying to cloud servers and is almost guaranteed to surface when Cline deploys on your behalf.

The root cause is layer stacking. SvelteKit's JS and CSS assets are content-hashed by Vite — a change to your code produces a new filename like chunk-abc123.js, which naturally busts the cache. But the HTML document that references those assets is not hashed. When any layer between your server and the browser (the browser's own cache, nginx, a CDN) serves a stale copy of the HTML, the user's browser loads old JS bundles. Everything looks fine from the server's perspective — the new build is there — but the user is looking at last week's app.

Why It's Intermittent

It looks random but it isn't. Whether a user sees the new version depends on when their browser last cached the HTML response relative to when you deployed. Some users hit the cache, some don't. Hard refresh bypasses the browser cache and fetches fresh HTML. That's why it "fixes" the problem temporarily — until the next navigation caches a stale page again.

Why Cloud Deploys Make It Worse

Locally, there's one layer between the Node process and the browser. On a VPS, there are at least two: nginx (or whatever reverse proxy) and the browser. If nginx isn't configured to pass through proper cache headers for SSR responses, it applies its own defaults — which usually means caching HTML. Add Cloudflare or any CDN in front and you've got three layers, each potentially serving stale content.

The Fix: Cache Headers in hooks.server.ts

This is the single highest-impact change. In every SvelteKit project, create a hooks.server.ts that sets correct Cache-Control headers:

ts
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
    const response = await resolve(event);

    // Hashed assets — safe to cache forever
    if (event.url.pathname.startsWith('/_app/immutable/')) {
        response.headers.set(
            'Cache-Control',
            'public, max-age=31536000, immutable'
        );
    }
    // HTML and SSR responses — browser must revalidate every time
    else if (!event.url.pathname.startsWith('/api/')) {
        response.headers.set('Cache-Control', 'no-cache');
    }

    return response;
};

no-cache does not mean "don't cache" — it means "cache it, but revalidate with the server before using it." This gives you the performance benefit of conditional requests (304 Not Modified) while ensuring the browser always checks for fresh HTML after a deploy.

The Fix: nginx Configuration

When Cline sets up nginx for a SvelteKit app, the config must explicitly handle cache headers. The default nginx behaviour will cache SSR responses and cause stale deploys:

nginx
# Inside your server block
location /_app/immutable/ {
    proxy_pass http://localhost:3000;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

location / {
    proxy_pass http://localhost:3000;
    # Do NOT cache HTML responses from SSR
    add_header Cache-Control "no-cache";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

The Fix: Clean Build on Every Deploy

The .clinerules already mention rm -rf .svelte-kit && npm run build as a troubleshooting step, but it should be the default deploy step, not a fallback. Stale .svelte-kit artifacts can cause the new build to silently include old code. The standard deploy sequence should be:

bash
rm -rf .svelte-kit build
npm run build
pm2 restart app-name    # restart, not reload

Use pm2 restart instead of pm2 reload. The reload command does a zero-downtime restart where the old process keeps serving requests while the new one boots. This is great for APIs, but for frontend apps it means some requests get the old HTML during the transition — exactly the intermittent staleness behaviour users report.

The Fix: Kill Orphan Service Workers

If at any point a service worker was registered — from a PWA experiment, a library that injects one, or a Cline task that added offline support and then removed it — the service worker will aggressively cache the app shell and serve stale content regardless of what the server returns. Service workers sit in front of the network, so they override your cache headers entirely.

If your project does not intentionally use service workers, add this cleanup to +layout.svelte:

svelte
<script>
    import { onMount } from 'svelte';
    import { browser } from '$app/environment';

    onMount(() => {
        if (browser && 'serviceWorker' in navigator) {
            navigator.serviceWorker.getRegistrations().then(registrations => {
                registrations.forEach(r => r.unregister());
            });
        }
    });
</script>

The Fix: Visible Build Version

When someone reports "my changes aren't showing," the first question is always "is the new code actually running?" A visible build version eliminates the guesswork:

In vite.config.ts:

ts
export default defineConfig({
    define: {
        '__BUILD_TIME__': JSON.stringify(new Date().toISOString()),
    }
});

Then display it somewhere — a footer, an admin page, or a console.log on app startup. When the build version is old, it's a cache or deploy problem. When it's current, the code change itself didn't work. This saves 30 minutes of guesswork every single time.

The Checklist

Bake these into .clinerules for every SvelteKit project:

  1. hooks.server.ts with cache headersno-cache on HTML, immutable on /_app/immutable/
  2. nginx configured explicitly — don't rely on defaults for SSR responses
  3. Clean build on every deployrm -rf .svelte-kit build && npm run build
  4. pm2 restart not pm2 reload — clean process switch, not zero-downtime overlap
  5. Service worker cleanup if no intentional PWA use
  6. Visible build version — timestamp or commit hash in the UI

If you're using Cloudflare in front, add one more: set a page rule for your domain that sets Cache Level: Bypass on HTML responses, or configure the cache to respect origin headers. Cloudflare's default edge caching behaviour will cache your SSR responses at the CDN layer even if your server sends correct headers, unless you explicitly tell it not to.

The Deployment Centre catches this

If you've built the Project Control Panel, the Deployment Centre tab pings /api/health and shows the build version. A stale deploy shows up immediately as a version mismatch — no guesswork needed.


Mobile: Capacitor, PWA, and App Store Deployment

If there's any intention of going to the mobile app store, plan for it during brainstorming, not after. Retrofitting mobile support is painful.

Capacitor is the recommended path for wrapping a web app into a native mobile shell. It works with any frontend framework (Svelte, React, Vue) and provides access to native device APIs (camera, filesystem, push notifications) through a plugin system. The key architectural decisions that Capacitor requires — like how routing works, how assets are bundled, and how native plugins are accessed — need to be baked into the project from Sprint 1.

PWA (Progressive Web App) is a great middle ground for testing prototypes without the pain of app store deployment. If the user isn't sure whether they need the app stores, start with PWA. It gives you home screen installation, offline capability, and push notifications on most platforms, without Apple or Google review processes.

Fastlane can automate the app store submission process — screenshots, metadata, code signing, uploading. For users who do need to publish to the stores, Cline should suggest and set up Fastlane as part of the deployment infrastructure.

Framework choice matters here. Svelte produces smaller bundle sizes and cleaner code, which translates to faster mobile performance. If the user has no framework preference and mobile is a target, Svelte with Capacitor is a strong recommendation. Make sure to include the SDKs needed for any outside service integrations (payment providers, analytics, auth services).


Desktop: Electron vs Tauri

Desktop apps require a framework that bridges web technologies to native OS capabilities.

Electron is the established choice — VS Code, Slack, and Discord all run on it. It bundles a full Chromium browser and Node.js runtime, which means guaranteed cross-platform rendering consistency. The downsides are significant: apps start at 100-150MB, consume hundreds of MB of RAM, and each instance runs its own Chromium process.

Tauri is the modern alternative, built on Rust. It uses the operating system's native webview instead of bundling Chromium, producing apps that are often under 10MB with 50% less RAM usage. Tauri 2.0 went stable in late 2024 and added iOS and Android support from a single codebase. The trade-off is that you're subject to cross-platform webview differences (WebKit on macOS vs Chromium-based WebView2 on Windows), though in practice these are rarely an issue for typical apps. You don't need deep Rust expertise — most logic stays in your JavaScript frontend, and Tauri's built-in plugins cover common native features.

For AI-coded projects specifically, Tauri's smaller surface area is actually an advantage — fewer things to go wrong, simpler builds, and the security-by-default model (capabilities must be explicitly enabled) prevents a class of issues that Electron apps are prone to.

Regardless of which you choose, desktop apps can turn into a frustrating cycle of debugging, rebuilding, reinstalling, re-running, same problem, repeat. Mitigate this by:

  • Building a control panel or companion logging interface from the start
  • Setting up extensive console logging and error reporting
  • Making rebuild/reinstall scripts that Cline can run quickly
  • Keeping the build-test-debug cycle as short as possible

Discuss the desktop target during brainstorming so the architecture, build tooling, and debugging infrastructure are all planned from Sprint 1.


Dependency Version Management

Claude and Cline's training data can be months out of date. When installing dependencies and frameworks, they often aren't installing the latest and most stable versions. This causes compatibility issues, deprecation warnings, and sometimes outright failures.

The rule: Before installing any dependency, Cline should do a web search to confirm what the latest stable and compatible versions are. This applies to frameworks, ORMs, CSS libraries, build tools — everything. Bake this into your .clinerules:

"Before running any npm install, pip install, or equivalent, search the web to confirm the latest stable version of each package. Do not rely on training data for version numbers."


Quick Reference

TargetToolKey Consideration
Static frontendNetlify / VercelFree tier, auto-deploy from GitHub
Backend servicesDocker Desktop (local), Hetzner (cloud)Always containerize, always separate dev/prod
Mobile (app store)Capacitor + FastlanePlan from Sprint 1, not after
Mobile (prototype)PWAQuick to ship, no store review
Desktop (lightweight)TauriSmaller, faster, security-first
Desktop (established)ElectronBigger ecosystem, guaranteed rendering

Observability Wiring (Do This in Sprint 1)

Your deploy script must upload source maps and record a release at build time. This is the irreversible plumbing — if a build ships without its source maps captured, every production error from that build is permanently minified gibberish. You cannot go back and fix it.

The ordering guarantee is: generate → upload → strip → package.

  1. Generate — build the app with source maps enabled (don't disable them in the prod build config)
  2. Upload — send the .map files + a release identifier (version + commit SHA) to your error tracker
  3. Strip — delete .map files from the build output so they're never served publicly
  4. Package — deploy the stripped build (Docker image, pm2 restart, etc.)

Getting this ordering wrong is the risk. A build that strips or disables source maps before upload produces an upload with nothing to symbolicate. A build that ships .map files publicly leaks your source code.

The scripts/deploy.sh template includes this step. It uses posthog-cli sourcemap upload by default — verify the exact flags against the current PostHog docs before first use. If the project uses a different tracker (Sentry, GlitchTip), the shape is the same: sentry-cli sourcemaps upload + sentry-cli releases.

The non-polling deploy principle still applies: the upload runs once as part of the one-shot deploy script, logs the result, and the script moves on. No looping to check upload status.

See Observability & Error Tracking for the full reasoning — why these two items (source maps and release tagging) belong in the foundation, and why everything else can wait.


CI/CD Pipeline Verification

Before deploying, always verify your CI pipeline actually built the image you're about to pull:

bash
gh run list --branch dev -L 3

If the latest run failed or didn't trigger, deploying will pull a stale image. See Deploy Verification — CI/CD Pre-Flight Checks for a complete pre-deploy script.


Next: Deploy Verification — Confirming your code actually reached production.