> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# n8n

> Deploy the official n8n Docker image on Upsun for workflow automation, AI agent orchestration, and webhook-driven integrations.

export const DynamicCodeBlock = ({language = 'yaml', filename, icon, lines, wrap, expandable, highlight, focus, children}) => {
  const STORAGE_KEY = 'upsun_versions_cache';
  const COMPOSABLE_STORAGE_KEY = 'upsun_composable_cache';
  const CACHE_TTL = 5 * 60 * 1000;
  const API_URL = 'https://meta.upsun.com/images';
  const COMPOSABLE_API_URL = 'https://meta.upsun.com/composable';
  const DEBUG_PREFIX = '[DynamicCodeBlock cache]';
  const [versionData, setVersionData] = useState(null);
  const [versionError, setVersionError] = useState(false);
  const [composableData, setComposableData] = useState(null);
  const [composableError, setComposableError] = useState(false);
  useEffect(() => {
    const fetchData = async () => {
      let cachedData = null;
      let cachedEtag = null;
      if (typeof localStorage !== 'undefined') {
        try {
          const cached = localStorage.getItem(STORAGE_KEY);
          if (cached) {
            const parsed = JSON.parse(cached);
            cachedData = parsed?.data || null;
            cachedEtag = parsed?.etag || null;
            if (cachedData && Date.now() - parsed.timestamp < CACHE_TTL) {
              return cachedData;
            }
          }
        } catch (err) {
          console.error('Failed to load from cache:', err);
        }
      }
      const requestHeaders = cachedEtag ? {
        'If-None-Match': cachedEtag
      } : {};
      console.debug(`${DEBUG_PREFIX} revalidating`, {
        storageKey: STORAGE_KEY,
        hasCachedData: Boolean(cachedData),
        hasCachedEtag: Boolean(cachedEtag)
      });
      const response = await fetch(API_URL, {
        headers: requestHeaders
      });
      if (response.status === 304 && cachedData) {
        console.debug(`${DEBUG_PREFIX} revalidated (304)`, {
          storageKey: STORAGE_KEY
        });
        if (typeof localStorage !== 'undefined') {
          try {
            const etag = response.headers.get('etag') || cachedEtag;
            localStorage.setItem(STORAGE_KEY, JSON.stringify({
              data: cachedData,
              etag,
              timestamp: Date.now()
            }));
          } catch (err) {
            console.error('Failed to refresh cache metadata:', err);
          }
        }
        return cachedData;
      }
      if (!response.ok) throw new Error(`API request failed: ${response.statusText}`);
      const data = await response.json();
      const etag = response.headers.get('etag');
      console.debug(`${DEBUG_PREFIX} refreshed (200)`, {
        storageKey: STORAGE_KEY,
        etag
      });
      if (typeof localStorage !== 'undefined') {
        try {
          localStorage.setItem(STORAGE_KEY, JSON.stringify({
            data,
            etag,
            timestamp: Date.now()
          }));
        } catch (err) {
          console.error('Failed to cache data:', err);
        }
      }
      return data;
    };
    fetchData().then(data => setVersionData(data)).catch(err => console.error('Failed to fetch version data:', err));
  }, []);
  const findHighestVersion = versionsMap => {
    if (!versionsMap || Object.keys(versionsMap).length === 0) return null;
    const entries = Object.entries(versionsMap);
    const active = entries.filter(([, v]) => v.upsun && v.upsun.status === 'supported' || v.upsun && v.upsun.status === 'deprecated');
    const candidates = active.length > 0 ? active : entries;
    let [highestName] = candidates[0];
    for (let i = 1; i < candidates.length; i++) {
      const [currentName] = candidates[i];
      const cp = currentName.split('.').map(Number);
      const hp = highestName.split('.').map(Number);
      for (let j = 0; j < Math.max(cp.length, hp.length); j++) {
        if ((cp[j] || 0) > (hp[j] || 0)) {
          highestName = currentName;
          break;
        } else if ((cp[j] || 0) < (hp[j] || 0)) {
          break;
        }
      }
    }
    return highestName;
  };
  const getVersion = (lang, requestedVersion = 'latest') => {
    if (lang === 'composable') {
      if (!composableData || !composableData.versions || Object.keys(composableData.versions).length === 0) return null;
      if (requestedVersion && requestedVersion !== 'latest') {
        return (requestedVersion in composableData.versions) ? requestedVersion : null;
      }
      return findHighestVersion(composableData.versions);
    }
    if (!versionData) return null;
    const imageData = versionData[lang];
    if (!imageData || !imageData.versions || Object.keys(imageData.versions).length === 0) {
      return null;
    }
    if (requestedVersion && requestedVersion !== 'latest') {
      return (requestedVersion in imageData.versions) ? requestedVersion : null;
    }
    return findHighestVersion(imageData.versions);
  };
  let code = typeof children === 'string' ? children : String(children || '');
  const codeLines = code.split('\n');
  while (codeLines.length > 0 && codeLines[0].trim() === '') codeLines.shift();
  while (codeLines.length > 0 && codeLines[codeLines.length - 1].trim() === '') codeLines.pop();
  if (codeLines.length > 0) {
    const indents = codeLines.filter(line => line.trim().length > 0).map(line => line.match(/^[ \t]*/)[0].length);
    const minIndent = Math.min(...indents);
    code = codeLines.map(line => line.slice(minIndent)).join('\n');
  }
  code = code.replace(/\{\{version:(.*?)\}\}/g, (match, params) => {
    const parts = params.split(':');
    const lang = parts[0];
    const ver = parts[1] || 'latest';
    const isComposable = lang === 'composable';
    const hasError = isComposable ? composableError : versionError;
    const dataReady = isComposable ? composableData !== null : versionData !== null;
    if (hasError) return '(unavailable)';
    if (dataReady) {
      const resolvedVersion = getVersion(lang, ver);
      return resolvedVersion || match;
    }
    return '...';
  });
  const codeBlockProps = {
    language,
    ...filename && ({
      filename
    }),
    ...icon && ({
      icon
    }),
    ...lines !== undefined && ({
      lines
    }),
    ...wrap !== undefined && ({
      wrap
    }),
    ...expandable !== undefined && ({
      expandable
    }),
    ...highlight && ({
      highlight
    }),
    ...focus && ({
      focus
    })
  };
  return <CodeBlock {...codeBlockProps}>{code}</CodeBlock>;
};

[n8n](https://github.com/n8n-io/n8n/pkgs/container/n8n) is a workflow automation platform with a visual editor, webhook triggers, and built-in AI agent nodes. This guide deploys the official Docker image on Upsun using Docker image support, with a persistent SQLite database for workflows, credentials, and execution history.

For prerequisites and Docker image limitations, see [Self-hosted services from public Docker images](/docs/add-services/docker-images).

If you don't need a persistent workflow UI and just want to run a job on demand or on a schedule, [task containers](/docs/configure-apps/tasks) or [crons](/docs/configure-apps/image-properties/crons) may be a simpler fit than n8n.

## Before you begin

1. [Install the Upsun CLI](/cli/install).
2. Run the following commands to log in and connect the current Git repository to the correct Upsun project. Replace `<PROJECT_ID>` with your project ID (run `upsun projects` to list them).

   ```bash theme={null}
   upsun login
   upsun auth:info
   upsun set-remote <PROJECT_ID>
   upsun project:info
   upsun environment:info
   ```

## 1. Configure n8n as an app

Add the following configuration to `.upsun/config.yaml`.

* This configuration uses [the startup command defined by the image](/docs/add-services/docker-images#startup-commands). The official n8n image already starts n8n and listens on `0.0.0.0:5678`, so this example does not set `web.commands.start` to override it.
* The `/home/node` mount contains n8n's SQLite database, configuration, credentials, and cache. Mounting the full home directory ensures both `.n8n` and `.cache` are writable and persistent.
* `HIGH_MEMORY` is a [container profile](/docs/configure-apps/image-properties/container_profile) — a preset ratio of memory to CPU. Choose the profile that matches n8n's actual resource needs.

```yaml theme={null}
applications:
  n8n:
    type: docker:1
    container_profile: HIGH_MEMORY

    # Pin the image tag for repeatable deploys. Avoid `latest` in production;
    # update this tag intentionally when you are ready to upgrade n8n.
    image:
      name: "ghcr.io/n8n-io/n8n:2.34.1"

    # Production baseline after deployment:
    #   upsun resources:set --size n8n:1 --disk n8n:2048
    # This keeps one n8n instance with 1 CPU and, under HIGH_MEMORY, roughly
    # 2 GB RAM. Increase disk for execution history and binary data.

    # /home/node exists in the official image. n8n stores its SQLite database
    # and instance configuration in .n8n and compiled editor assets in .cache.
    mounts:
      "/home/node":
        source: storage
        source_path: node-home

    variables:
      env:
        # Keep the encryption key out of Git and set it as a sensitive runtime
        # variable instead:
        #   upsun variable:create --level environment --prefix env: --name N8N_ENCRYPTION_KEY --value '<LONG_RANDOM_ENCRYPTION_KEY>' --sensitive true --visible-runtime true --visible-build false

        # Upsun starts the wrapped image from /app. Set the n8n user folder to
        # the persistent mount instead of allowing it to default to /app/.n8n.
        N8N_USER_FOLDER: "/home/node"
        N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"

        # n8n serves HTTP directly on TCP port 5678. Trust one proxy hop so n8n
        # uses the forwarding headers supplied by the Upsun router.
        N8N_LISTEN_ADDRESS: "0.0.0.0"
        N8N_PROXY_HOPS: "1"

        # Change these values to the timezone used by scheduled workflows.
        GENERIC_TIMEZONE: "Etc/UTC"
        TZ: "Etc/UTC"

        # Register tini as a child subreaper because the image entrypoint runs
        # inside Upsun's application wrapper rather than as PID 1.
        TINI_SUBREAPER: "true"

    web:
      upstream:
        # n8n serves HTTP directly on a TCP port; passthrough lets the Upsun
        # router forward requests without expecting a local Unix socket.
        socket_family: tcp
      locations:
        "/":
          passthru: true
          request_buffering:
            enabled: false

# This project does not need Upsun-managed services because this example uses
# n8n's persistent SQLite database.
services: null

routes:
  # Use a dedicated subdomain so the editor and webhooks share one public URL.
  "https://n8n.{default}/":
    type: upstream
    upstream: "n8n:http"
    cache:
      enabled: false
```

## 2. Set or update the n8n encryption key

Set `N8N_ENCRYPTION_KEY` as a sensitive runtime environment variable instead of committing it to `.upsun/config.yaml`.

1. Generate a long random value for the encryption key:

   ```bash theme={null}
   openssl rand -hex 32
   ```

2. Use the generated value in place of `<LONG_RANDOM_ENCRYPTION_KEY>`:

   ```bash theme={null}
   upsun variable:create --level environment --prefix env: --name N8N_ENCRYPTION_KEY --value '<LONG_RANDOM_ENCRYPTION_KEY>' --sensitive true --visible-runtime true --visible-build false
   ```

If the `N8N_ENCRYPTION_KEY` variable already exists, update it:

```bash theme={null}
upsun variable:update env:N8N_ENCRYPTION_KEY --level environment --value '<LONG_RANDOM_ENCRYPTION_KEY>' --sensitive true --visible-runtime true --visible-build false
```

Keep the key backed up securely. Changing it after n8n has stored credentials makes those credentials unreadable unless you migrate them using the previous key.

## 3. Deploy the app

Commit the changes to `.upsun/config.yaml` and push them to Upsun:

```bash theme={null}
git add .upsun/config.yaml
git commit -m "Add n8n Docker image app"
upsun push
```

## 4. Allocate n8n resources

The first deployment [defaults to a minimal resource allocation](/docs/manage-resources/resource-init) regardless of app type (0.5 CPU, 512 MB disk, and [1408 MB memory](/docs/manage-resources/adjust-resources#shared-cpu-container-sizes)). That's enough to boot n8n and run small workflows, but workflow concurrency, execution history, and binary data can require more resources. Set a baseline for the app:

```bash theme={null}
upsun resources:set --size n8n:1 --disk n8n:2048
```

With `container_profile: HIGH_MEMORY`, the n8n app gets 1 CPU, about 2 GB RAM, 2 GB disk, and one instance. Increase CPU and memory for concurrent or memory-intensive workflows, and [increase the disk size](/docs/manage-resources/adjust-resources#vertical-scaling) as execution history and binary data grow.

If you changed resources after the app was already deployed, redeploy the environment:

```bash theme={null}
upsun environment:redeploy
```

## 5. Set the public URLs

List the routes and copy the HTTPS hostname mapped to the `n8n` app:

```bash theme={null}
upsun route:list
```

Replace `<N8N_HOSTNAME>` with that hostname, without a path:

```bash theme={null}
upsun variable:create --level environment --prefix env: --name N8N_WEBHOOK_URL --value 'https://<N8N_HOSTNAME>/' --visible-runtime true --visible-build false

upsun variable:create --level environment --prefix env: --name N8N_EDITOR_BASE_URL --value 'https://<N8N_HOSTNAME>/' --visible-runtime true --visible-build false
```

If either variable already exists, update it:

```bash theme={null}
upsun variable:update env:N8N_WEBHOOK_URL --level environment --value 'https://<N8N_HOSTNAME>/' --visible-runtime true --visible-build false

upsun variable:update env:N8N_EDITOR_BASE_URL --level environment --value 'https://<N8N_HOSTNAME>/' --visible-runtime true --visible-build false
```

These variables make webhook URLs and editor links use the public HTTPS route.

<Note>
  Preview environment URLs are ephemeral by default, so `<N8N_HOSTNAME>` changes with every new preview environment. If external services need a stable webhook URL, [set up a custom domain](/docs/domains/steps/custom-domains-preview-environments) for the environment.
</Note>

## 6. Validate the deployment

List the routes and confirm that the HTTPS route mapped to the `n8n` app appears. You'll use its hostname in place of `<N8N_HOSTNAME>` in the next step.

```bash theme={null}
upsun route:list
```

Check the health endpoint. Replace `<N8N_HOSTNAME>` with the hostname for the n8n app:

```bash theme={null}
curl https://<N8N_HOSTNAME>/healthz
```

Expected response:

```json theme={null}
{"status":"ok"}
```

Open `https://<N8N_HOSTNAME>/` to create the n8n owner account.

If the health check doesn't return this, or you run into other issues deploying the app, [debug the app over SSH](/docs/add-services/docker-images#debug-an-app-over-ssh) to check `/var/log/app.log` and the container's process state.

## Updating the image version

To use a different n8n version, update the version in `image.name`:

```yaml theme={null}
applications:
  n8n:
    type: docker:1
    image:
      name: "ghcr.io/n8n-io/n8n:<VERSION>"
```

Then, commit and push the change:

```bash theme={null}
git add .upsun/config.yaml
git commit -m "Update n8n image version"
upsun push
```

## Limitations

* This configuration uses SQLite and one n8n instance, so it can't handle high workflow volume and provides no redundancy during restarts, deployments, or instance failures.

  Don't fix this by [increasing instance count](/docs/manage-resources/adjust-resources#horizontal-scaling) alone: [`storage` mounts are shared between instances of the same app](/docs/configure-apps/image-properties/mounts), so multiple instances would write to the same SQLite file concurrently — something SQLite can't handle safely.

  To scale horizontally and gain high availability, switch to queue mode: [PostgreSQL](/docs/add-services/postgresql) for shared state, [Valkey](/docs/add-services/valkey) (Upsun's Redis-compatible service) for the job queue, and separate worker instances. Until then, schedule redeploys during low-traffic windows and design workflows to tolerate an interrupted execution being retried.

## Operational considerations

* Backups: [Production environments back up automatically](/docs/environments/backup) with only 2 days of retention by default, and non-production environments aren't backed up at all. Since this configuration has no external database, that backup is the only copy of your workflows, credentials, and execution history — consider a longer retention policy and n8n's own execution-data pruning settings to limit how much there is to back up.
* Monitoring/alerts: Upsun has no built-in alerting. Forward n8n's logs to an external tool via [log forwarding](/docs/observability/logs/forward-logs), or use [Blackfire](/docs/observability/application-metrics/blackfire) for performance profiling, then alert from whatever tool receives that data.
* SMTP: n8n needs an SMTP provider for workflow notification emails. You can optionally use Upsun's [built-in SMTP proxy](/docs/development/email) instead of a third-party provider — preview environments are limited to 12,000 email credits per calendar month.

## Related

* n8n is commonly used to orchestrate AI agent workflows. If those workflows need to manage Upsun infrastructure itself (for example, triggering deploys or querying environment status from an agent), see the [Upsun MCP Server](/docs/get-started/ai/using-the-mcp).
