Skip to main content
This tutorial covers building an AI news digest where a Node.js frontend takes user requests and a Python worker does the slow work of fetching RSS feeds and calling OpenAI. The two apps communicate through Redis. You’ll see how Upsun workers handle background processing, how apps share services, and how multi-app deployment works.

What you’re building

Users pick some topics, click a button, and get a summary of recent news. Behind the scenes: the frontend drops a job into Redis, a Python worker grabs it, fetches a bunch of RSS feeds, sends the articles to OpenAI, and stores the result. The frontend polls until it’s done. Why split it up like this? The web app never blocks. Fetching RSS feeds is slow. Calling OpenAI is slower. If you did that inline, users would stare at a spinner for 20 seconds. With a worker, the request returns immediately and processing happens in the background. Workers are not the same as cron jobs. Cron runs on a schedule: every hour, daily at 8am. Workers run all the time, grabbing tasks as they show up. Pick workers when users want results fast. Pick cron when nobody is waiting.

Architecture

The frontend and worker live in the same Upsun project but run as separate containers. They share Redis through relationships. Only the frontend gets HTTP routes.

Prerequisites

You need Node.js 22+, Python 3.12+, Docker (for running Redis locally), an OpenAI API key from platform.openai.com, the Upsun CLI, and Git.

Project structure

Upsun multi-app projects need each app in its own directory:

Building the frontend

1. Initialize the project

Install dependencies:
express runs the web server, ioredis talks to Redis, uuid generates job IDs. TypeScript and tsx are for development.

2. Configure TypeScript

Create tsconfig.json: View source on GitHub
Update package.json:

3. Create the Express server

Create src/index.ts: View source on GitHub
The getRedisConfig() function handles the Upsun-specific bit. On Upsun, service credentials arrive in PLATFORM_RELATIONSHIPS, base64 encoded. We decode it to get the Redis host and port. Locally, it falls back to localhost:6379. This pattern gets old if you have many services, but it works. Three endpoints: POST /api/digest queues a new job, GET /api/digest/:jobId checks status and returns the result, GET /api/digest returns the most recent completed digest.

4. Create the frontend UI

Create public/index.html. The full file is in the repository. It has topic selection buttons, a generate button, polling logic, and markdown rendering. The design is based on the chat interface from the LangChain chatbot tutorial (dark theme, lime accents, Space Grotesk), adapted for the digest workflow instead of a streaming chat. The polling is basic:
Every 2 seconds, check if the job finished. When it does, stop polling and show the result. You could use WebSockets or Server-Sent Events for real-time updates, but polling is fine for a demo and much simpler to debug.

Building the worker

1. Initialize the Python project

Create requirements.txt: View source on GitHub
redis for the queue, openai for summaries, feedparser for RSS parsing, httpx for HTTP requests (I like it better than requests), python-dotenv for local env files.

2. Create the worker script

Create main.py: View source on GitHub
The worker uses brpop (blocking right pop) to wait for jobs. This is better than polling in a loop because Redis wakes the worker only when something arrives. The 30-second timeout lets us check for shutdown signals. The job flow: pop from digest:queue, update status to “processing”, fetch RSS feeds, call OpenAI, store result and mark “completed”, save job ID as the latest.

Local development

Start Redis

Set up the frontend

Create .env:
Run the dev server:

Set up the worker

Create .env:
Run the worker:

Test the app

Open http://localhost:3000, pick some topics, click “Generate Digest”. Both terminals should light up: the frontend logging the queued job, the worker logging article fetches and completion. If nothing happens, check that Redis is running and both apps can connect.

Deploying to Upsun

Multi-app configuration

The .upsun/config.yaml file defines both apps and how they share Redis: View source on GitHub
Each app has its own source.root directory. The frontend app uses web: because it serves HTTP. The worker app uses workers: because it runs in the background, no HTTP. Both have relationships.redis pointing to the same Redis service. That’s how they talk to each other. Routes only point to frontend:http since workers don’t get public URLs. The one thing that confused me at first: the worker application contains a workers: block. So you have a worker app that defines workers. The naming is a bit circular, but it makes sense once you see it.

Initialize Git

Create Upsun project

Follow prompts for organization, project name, region, and plan.

Set the OpenAI API key

The --sensitive true flag encrypts it. Won’t show up in logs or the UI.

Deploy

Watch the build logs. Both apps build separately: npm install and tsc for the frontend, pip install for the worker. When done, Upsun starts both containers. The frontend takes traffic, the worker waits for jobs.

Access your app

Opens in your browser.

Testing

Try the full flow: open the app, select topics, click “Generate Digest”, watch it go from “pending” to “processing” to “completed”, read the summary. Check logs from both apps:
Worker logs show the job moving through:

Customization

Add more topics

Edit RSS_FEEDS in worker/main.py:
Then add matching buttons in the frontend. Some feeds are flaky, so test them locally first.

Change the AI model

gpt-4o-mini is cheap and fast. gpt-4o is smarter but costs more.

Adjust digest format

Edit the prompt in generate_digest():

Scale the worker

If one worker can’t keep up, add more. In .upsun/config.yaml:
Two workers compete for jobs from the same queue. Jobs get processed in parallel. Be careful with rate limits on RSS feeds and OpenAI if you scale too much.

Add scheduled digests

Want an automatic digest every morning? Add a cron alongside the worker:
Create schedule_digest.py to push a job with all topics.

Workers vs cron jobs

Workers make sense when users are waiting. They also work well when jobs arrive unpredictably or take variable time. Cron makes sense for scheduled tasks where nobody is watching. Daily reports, hourly syncs, that kind of thing. This example uses workers because someone clicks a button and wants results. A cron job would only generate digests at predetermined times.

Troubleshooting

Worker not picking up jobs

Check if it’s actually running:
If it’s running but not processing, check Redis connectivity:

“OPENAI_API_KEY is required” error

If missing, create it (see deployment section).

Digest takes too long

Some RSS feeds are slow or broken. Check logs:
Remove problematic feeds or bump the timeout in fetch_feed().

Jobs stuck in “processing”

The worker probably crashed. Check logs:
Jobs expire after 1 hour anyway. Or clear manually:

Build fails for worker

Usually a Python version issue. If you need an older version, change type: "python:3.11" in the config.

Wrapping up

You now have a Node.js frontend for users and a Python worker for background processing, with Redis in the middle. The frontend never blocks on slow operations. Same pattern works for image processing, PDF generation, sending emails, data imports, any AI inference. Keep the web app fast, move the slow stuff to workers.

Resources

For questions, check the Upsun Community Forum or open an issue in this repo.
Last modified on May 13, 2026