DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Adding a Custom Domain and SSL to AWS EC2
  • Implementing Asynchronous Communication Between Microservices Using Kafka and Spring Boot
  • How to Push Docker Images to AWS Elastic Container Repository Using GitHub Actions
  • How to Use Jenkins Effectively With ECS/EKS Cluster

Trending

  • Bringing Graph Analytics to Snowflake With Neo4j
  • Containerizing LLMs: Best Practices for Docker-Based AI Workloads
  • Natural IDs in Your Database. I Am Telling You for the Last Time!
  • Porting GPU Drivers to Rust on ARM64: The Hardest Trial for Kernel-Level Computing
  1. DZone
  2. Software Design and Architecture
  3. Containers
  4. Understand the Sidecar Pattern by Deploying n8n to AWS Fargate

Understand the Sidecar Pattern by Deploying n8n to AWS Fargate

Learn how to deploy n8n Task Runners as AWS Fargate sidecars for isolated code execution, independent resources, and scalable workflow automation.

By 
Iyanuoluwa Ajao user avatar
Iyanuoluwa Ajao
·
Sep. 17, 26 · Analysis
Likes (1)
Comment
Save
Tweet
Share
238 Views

Join the DZone community and get the full member experience.

Join For Free

A sidecar is a container that runs alongside another container as part of the same deployment unit. Just because two containers are in the same cluster or deployed around the same time doesn't make one a sidecar. 

There are two things that make a sidecar. First is that they share a network namespace, so they can reach each other over localhost rather than a network address. Second, they share a lifecycle. This means that they are created together, scaled together, and by default torn down together. Neither container has an existence independent of the other.

The problem it solves is giving a specific concern its own boundary. For example, it can have its own filesystem, its own memory space, and often its own permissions or dependency set, without giving up the simplicity of deploying and operating one unit. You get isolation without paying for the operational overhead of running and coordinating a fully separate service.

The test that defines the pattern across all of these is this: does it live and die with its partner container as one unit of deployment? If yes, it's a sidecar. If you have to reach it by hostname, through service discovery, or via a queue, it isn't one anymore. That is a separate service that happens to sit next to the first.

That test matters because two adjacent patterns get called "sidecar" when they aren't:

  • Decoupled worker/microservice. A separately deployed container, reached over the network, scaled on its own. A web application offloading work to Celery workers via Redis is a common instance of this: the app enqueues a job (send this signup email), a pool of workers pulls jobs off the queue independently, and neither side shares a network namespace or a lifecycle with the other. The workers scale on queue depth, not on how many web replicas are running, and a web app restart doesn't take queued or in-flight jobs down with it. n8n has its own version of the same shape: "queue mode," where a main node accepts webhooks and separate worker nodes pull jobs off a Redis queue. It's tempting to call either of these a sidecar relationship since the worker and the web app do feel paired, but neither qualifies: they don't share a deployment unit, and killing one doesn't touch the other.
  • Ambassador/adapter. A container that proxies or translates traffic on its parent's behalf, like the Envoy example above, is actually this, more precisely. Structurally it's still a sidecar; it just gets a more specific name for what it does.

Using n8n to Understand It

What n8n Is

n8n is a workflow automation platform like Zapier, but self-hostable and node-based rather than form-based. A handful of components make up a running instance:

  • The editor/UI, where workflows are built visually as a graph of nodes.
  • The main process, which serves that UI, listens for webhooks, and orchestrates workflow execution. The workflow execution decides what runs next, passing data between nodes and recording results.
  • Nodes, the individual units of a workflow: trigger nodes (a webhook arrives, a schedule fires), action nodes (call an API, write to a database, send an email), and the Code node. 
    The code node lets you drop in arbitrary JavaScript or Python to transform data however the built-in nodes can't. The code node is relevant in this article. 
  • The database, where workflow definitions, credentials, and execution history persist. In this article, Postgres is used. 

For most of what n8n does, the main process is the only thing doing work: routing a webhook, calling an API, writing a database row. The exception is the Code node, and that exception is the whole reason task runners exist.

The Task Runner Feature and Its Use Case

By default, a Code node's JavaScript or Python executes inside n8n's main process. This main process holds the database connection, the encryption key, and every credential stored in every workflow you've built. 

That's fine for trusted, well-understood scripts. It becomes a real problem the moment the code in that node is untrusted, third-party, or arbitrary enough that you can't fully audit it before it runs. By the way, that is how most Code nodes are used in practice. 

Task runners exist to solve exactly that use case: run Code node logic somewhere the main process's credentials and connections aren't reachable from it, without turning "write some JavaScript to reshape this JSON" into a separately deployed microservice every time.

Going Deep on the Task Runner Feature

n8n ships two modes for this:

  • Internal mode (the default) runs Code nodes inline, in-process. No isolation. This is the fastest to set up, but the weakest boundary.
  • External mode moves execution into a separate runner process entirely. That process connects back to the main n8n instance over a broker (an authenticated connection the main process listens on) and receives individual tasks to execute rather than having any standing access to n8n's internals. The runner never touches the database connection, the encryption key, or stored credentials directly; it only ever sees the specific input data for the task it's been handed.

External mode goes further than just "a different process," too. The runner's own configuration (the n8n-task-runners.json file built in Phase 4) sets explicit allowlists — which environment variables the runner process can see at all, and which JavaScript built-ins or Python modules it's permitted to import, standard library and third-party tracked separately. So the boundary isn't just "different memory space," it's "different memory space, plus a declared, auditable list of exactly what this process is allowed to touch."

That's a specific concern (arbitrary code execution) given its own boundary, without turning it into a fully independent service you have to deploy, discover, and monitor separately. It's the sidecar problem, stated exactly: external mode gives you the isolation; running the external runner as its own container in the same task definition is what makes that isolation a sidecar rather than just a separate process sharing a machine.

Why This Needs to Scale Independently and Why "In the Same Container" Isn't Enough

Most n8n deployment guides run n8n with task runners in internal mode, or with the external runner living inside the same container as the main process. For example, you will see guides about deploying n8n on a single EC2 instance, Render, DigitalOcean, or any platform's basic tier. That gets you the process isolation, which solves the security half of the problem. It doesn't solve the other half, which is that a runner sharing a container with the app can't be scaled, resourced, or restarted independently of it.

That stops mattering the moment Code-node execution becomes the actual bottleneck rather than webhook handling or UI traffic. Imagine workflows doing heavy data transformation in Python, running numpy/pandas operations across large payloads, or executing many Code nodes concurrently. If the runner is bundled into the main container, giving it more CPU means giving the entire n8n instance more CPU, whether the UI and webhook layer need it or not. There's no way to say "the runner needs 2 more vCPUs, n8n itself is fine". 

Why AWS Fargate's Task Definition Is the Right Fit

A Fargate task definition lets each container in the task carry its own CPU and memory reservation, its own health check, and its own essential flag governing what happens if it fails while still keeping every container in the task on one shared network interface. That's the sidecar promise made literal: isolation and independent resourcing for the runner, without losing the operational simplicity of one task, one deploy, one thing to scale as a unit when you do want to scale both together.

The rest of this guide deploys exactly that: one Fargate task, two containers, wired together the way the definition above requires. Each infrastructure decision below gets tied back to a specific part of what's laid out here, so that by the end, the concept isn't something read once at the top, but it's something built.

Prerequisites

  • AWS account with billing enabled
  • A domain you control, with DNS access
  • Docker installed locally, with docker buildx available
  • AWS CLI configured (aws configure) with permissions for ECR, ECS, RDS, ACM, and IAM
  • The runner image source (Dockerfile + n8n-task-runners.json) — built in Phase 4

Architecture

Markdown
 
User's Browser (HTTPS)
       |
[Application Load Balancer] <- Certificate Manager (SSL Cert)
       | (Port 5678, HTTP internal)
[ECS Fargate Task]
   |-- Container: n8n (main)         <-- shared network namespace -->  Container: n8n-runner (sidecar)
       | (Port 5432, PostgreSQL)
[RDS PostgreSQL Database]


The load balancer and RDS layers are ordinary AWS plumbing. The box in the middle is where the sidecar relationship actually lives. There is one task and two containers, each with its own resourcing.

Phase 1: RDS PostgreSQL

  1. RDS Console → Create database → Standard create → Engine: PostgreSQL
  2. DB instance identifier: n8n-db. Master username: postgres. Generate and save a strong master password.
  3. Instance size: db.t4g.micro
  4. Storage: 20 GB gp3, autoscaling on, max 100 GB
  5. Connectivity: the VPC you'll use throughout. Public access: No. New security group: n8n-db-sg, left empty for now.
  6. Additional configuration → Initial database name: n8n. Skip this and n8n fails on first connect with "database does not exist" — the DB instance identifier names the server, this field names the database inside it.
  7. Create, wait for "Available," copy the endpoint from Connectivity & security.

Phase 2: ACM Certificate

n8n requires HTTPS for webhooks to function

  1. Certificate Manager, in the same region you'll deploy the Load Balancer in → Request a public certificate
  2. Domain name: n8n.yourdomain.com
  3. Validation method: DNS validation
  4. Create the CNAME record ACM provides at your registrar. If your registrar auto-appends your domain to the Host field, paste only the portion before your domain — the full string duplicates it and validation never completes.
  5. Wait for status: Issued

Phase 3: Security Groups

Two connections need rules:

Security group Inbound rule Purpose
n8n-alb-sg 443 from 0.0.0.0/0 Public HTTPS
n8n-ecs-sg 5678 from n8n-alb-sg ALB → n8n container
n8n-db-sg (edit existing) 5432 from n8n-ecs-sg n8n container → RDS


Phase 4: Build and Push the Runner Image

Dockerfile:

Dockerfile
 
FROM n8nio/runners:1.121.0
USER root
RUN cd /opt/runners/task-runner-javascript && pnpm add moment uuid adm-zip
RUN cd /opt/runners/task-runner-python && uv pip install numpy pandas pydantic requests boto3 certifi
COPY n8n-task-runners.json /etc/n8n-task-runners.json
ENV N8N_RUNNERS_CONFIG_FILE=/etc/n8n-task-runners.json
USER runner


It starts from n8n's own n8nio/runners base (containing the launcher and both runner processes), adds only the dependencies workflows actually need, and drops back to a non-root user once the root-only install steps finish. 

n8n-task-runners.json is where the isolation described above stops being architectural and becomes enforced:

JSON
 
{
  "task-runners": [
    {
      "runner-type": "javascript",
      "health-check-server-port": "5681",
      "allowed-env": ["PATH", "GENERIC_TIMEZONE", "NODE_OPTIONS"],
      "env-overrides": {
        "NODE_FUNCTION_ALLOW_BUILTIN": "crypto,zlib",
        "NODE_FUNCTION_ALLOW_EXTERNAL": "moment,uuid,adm-zip"
      }
    },
    {
      "runner-type": "python",
      "health-check-server-port": "5682",
      "env-overrides": {
        "N8N_RUNNERS_STDLIB_ALLOW": "json,zipfile,io,base64,datetime,re,math,random,statistics",
        "N8N_RUNNERS_EXTERNAL_ALLOW": "numpy,pandas,pydantic,requests,boto3,certifi"
      }
    }
  ]
}


allowed-env restricts which environment variables the runner process can see; N8N_RUNNERS_STDLIB_ALLOW / EXTERNAL_ALLOW restrict which Python modules it can import, stdlib and third-party separately. One container, two runner processes — the launcher inside n8nio/runners spawns both.

Build and push:

Shell
 
docker buildx build -t n8nio/runners:custom .

aws ecr create-repository --repository-name n8n-runners --region us-east-1

aws ecr get-login-password --region us-east-1 \
  | docker login --username AWS --password-stdin <account-id>.dkr.ecr.us-east-1.amazonaws.com

docker tag n8nio/runners:custom <account-id>.dkr.ecr.us-east-1.amazonaws.com/n8n-runners:custom
docker push <account-id>.dkr.ecr.us-east-1.amazonaws.com/n8n-runners:custom


--username AWS is a fixed literal, not your actual username — ECR auth always uses it. The password piped via --password-stdin is a short-lived token generated by the CLI, not your account password.

Phase 5: The Task Definition

This is where the two containers become an actual sidecar pair, and where the independent-resourcing argument from the introduction becomes a real field rather than a claim.

JSON
 
{
  "family": "n8n-task",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "1024",
  "memory": "2048",
  "executionRoleArn": "arn:aws:iam::<account-id>:role/n8n-task-execution-role",
  "containerDefinitions": [
    {
      "name": "n8n",
      "image": "n8nio/n8n:1.121.0",
      "essential": true,
      "entryPoint": ["sh", "-c"],
      "command": [
        "mkdir -p /home/node/certs && wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem -O /home/node/certs/rds-ca.pem && /docker-entrypoint.sh"
      ],
      "portMappings": [{ "containerPort": 5678, "protocol": "tcp" }],
      "environment": [
        { "name": "DB_TYPE", "value": "postgresdb" },
        { "name": "DB_POSTGRESDB_HOST", "value": "<rds-endpoint>" },
        { "name": "DB_POSTGRESDB_PORT", "value": "5432" },
        { "name": "DB_POSTGRESDB_DATABASE", "value": "n8n" },
        { "name": "DB_POSTGRESDB_USER", "value": "postgres" },
        { "name": "DB_POSTGRESDB_SSL_CA", "value": "/home/node/certs/rds-ca.pem" },
        { "name": "DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED", "value": "false" },
        { "name": "WEBHOOK_URL", "value": "https://n8n.yourdomain.com/" },
        { "name": "GENERIC_TIMEZONE", "value": "Africa/Lagos" },
        { "name": "N8N_RUNNERS_ENABLED", "value": "true" },
        { "name": "N8N_RUNNERS_MODE", "value": "external" },
        { "name": "N8N_RUNNERS_BROKER_LISTEN_ADDRESS", "value": "0.0.0.0" },
        { "name": "N8N_RUNNERS_BROKER_PORT", "value": "5679" }
      ],
      "secrets": [
        { "name": "DB_POSTGRESDB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:<region>:<account-id>:secret:n8n/db-password" },
        { "name": "N8N_ENCRYPTION_KEY", "valueFrom": "arn:aws:secretsmanager:<region>:<account-id>:secret:n8n/encryption-key" },
        { "name": "N8N_RUNNERS_AUTH_TOKEN", "valueFrom": "arn:aws:secretsmanager:<region>:<account-id>:secret:n8n/runners-auth-token" }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": { "awslogs-group": "/ecs/n8n-task", "awslogs-region": "<region>", "awslogs-stream-prefix": "n8n" }
      }
    },
    {
      "name": "n8n-runner",
      "image": "<account-id>.dkr.ecr.<region>.amazonaws.com/n8n-runners:custom",
      "cpu": 512,
      "memory": 1024,
      "essential": false,
      "dependsOn": [{ "containerName": "n8n", "condition": "START" }],
      "environment": [
        { "name": "N8N_RUNNERS_TASK_BROKER_URI", "value": "http://localhost:5679" }
      ],
      "secrets": [
        { "name": "N8N_RUNNERS_AUTH_TOKEN", "valueFrom": "arn:aws:secretsmanager:<region>:<account-id>:secret:n8n/runners-auth-token" }
      ],
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -f http://localhost:5680/healthz || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 20
      },
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": { "awslogs-group": "/ecs/n8n-task", "awslogs-region": "<region>", "awslogs-stream-prefix": "n8n-runner" }
      }
    }
  ]
}


Five fields here map directly back to the introduction:

Per-container cpu/memory on n8n-runner. This is the independent-resourcing argument made literal. The runner gets its own 512 CPU units and 1024 MB, carved out of the task total, separate from whatever n8n is allotted. If Code-node execution turns out to be the bottleneck, this is the number you raise without touching the main container's allocation at all. That's the exact thing a same-container runner can't offer you.

networkMode: awsvpc is the mechanical basis of "shared network namespace." Every container in the task gets one elastic network interface between them. This is the setting that makes Phase 3's missing security group rule make sense. There's one network surface, not two.

N8N_RUNNERS_TASK_BROKER_URI: http://localhost:5679 only works because of the line above. The runner reaches n8n over localhost because they are the same task. If this pointed anywhere else, you would have built the decoupled-worker pattern from the introduction instead, no matter what you called the container. 

A shared N8N_RUNNERS_AUTH_TOKEN, pulled from Secrets Manager by both containers. Sharing a network namespace means the runner is reachable by anything else in the task. The isolation the whole pattern exists for still needs a trust boundary at the process level, not just the network level. A plaintext token here would defeat that, since task definitions are readable by anyone with ecs:DescribeTaskDefinition.

essential: false on the runner. This governs how tightly the two containers' lifecycles are actually coupled. essential: true would mean a runner crash tears down the whole task, main container included. false means the runner can crash and recover independently: Code-node executions fail until it's back, but the UI and webhooks keep serving. The pattern doesn't mandate one answer; it just means this has to be a decision, not a default you inherited.

The health check on port 5680 hits the launcher's own endpoint, separate from the per-runner-type ports (5681 JS, 5682 Python) set in Phase 4's config file. ECS is checking the supervisor, not each runner process individually.

Register it:

aws ecs register-task-definition --cli-input-json file://n8n-task-def.json 

Phase 6: Cluster, Service, and Load Balancer

  1. ECS → Create cluster → n8n-cluster → Infrastructure: AWS Fargate
  2. Create a service inside it:
    • Task definition: n8n-task, latest revision
    • Desired tasks: 1
    • Networking: your VPC, at least two subnets across AZs, security group n8n-ecs-sg, public IP on
    • Load balancing: Application Load Balancer, listener on 443 using the Phase 2 certificate
    • Target group: HTTP, port 5678, health check path /healthz
  3. Create, wait for steady state.

Notice the target group and health check only ever reference the n8n container. It did not mention n8n-runner at all.  The n8n-runner container doesn't get a port that maps to the load balancer, doesn't get its own listener, doesn't get its own DNS entry. Everything that makes it reachable from outside the task goes through n8n .

Phase 7: DNS

At your registrar, add a CNAME: Host n8n, Value = your Load Balancer's DNS name. Confirm with nslookup n8n.yourdomain.com once it propagates.

Verifying the Sidecar Relationship

Visiting https://n8n.yourdomain.com and completing owner setup confirms the main container and database are working. To confirm the runner specifically:

  1. Create a workflow with a Code node (JavaScript or Python), and run it.
  2. Pull CloudWatch logs for both streams (/ecs/n8n-task, prefixes n8n and n8n-runner).

The n8n-runner stream should show the launcher starting both runner processes and reporting a broker connection. The n8n stream should show the Code node's execution dispatched out rather than run inline. If the workflow completes but nothing appears in n8n-runner's logs, check N8N_RUNNERS_MODE=external on the main container first. That's the setting that actually hands execution off instead of running it in-process regardless of what else is configured.

AWS Docker (software) Load balancing (computing)

Opinions expressed by DZone contributors are their own.

Related

  • Adding a Custom Domain and SSL to AWS EC2
  • Implementing Asynchronous Communication Between Microservices Using Kafka and Spring Boot
  • How to Push Docker Images to AWS Elastic Container Repository Using GitHub Actions
  • How to Use Jenkins Effectively With ECS/EKS Cluster

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook