VPS guide
Run background workers on your own VPS
Use your server for AI jobs, webhooks, queues, and scheduled work without waiting for product-specific support.
June 29, 2026 / 9 min read
What a worker is
A worker is just another long-running process on your server. It does not have to serve web traffic. It can read jobs from a database or queue, call APIs, generate assets, send email, process webhooks, or run scheduled maintenance.
The important part is ownership. Do not make the phone, browser, or a single HTTP request own the job. Those clients can disconnect, sleep, refresh, or time out. The durable state should live in your database, storage bucket, or queue, and the worker should keep moving the job forward from the VPS.
The reliable pattern
- 1. Create a job row when the user starts work.
- 2. Upload any source files to durable storage.
- 3. Let the worker claim pending jobs and update status.
- 4. Store generated outputs before marking the job ready.
- 5. Let the app poll, subscribe, or refresh from the database.
For a Supabase app, that usually means tables for sessions, jobs, and generated records, plus Storage buckets for uploaded source files and final assets. The mobile or web app should show progress from those records instead of relying on one request staying alive.
The Supabase shape
Keep user-facing reads behind Row Level Security, and keep private mutation power on the server. Never put the Supabase service-role key in your mobile or browser app. The app can create a session, upload a selfie or source file, and read its own status. The VPS worker can use the service-role key to claim jobs, call private APIs, and write final outputs.
A practical starting point is a sessions table, a jobs table, an outputs table, and Storage buckets for source uploads and generated files. The job row should include status, attempts, locked_by, locked_until, last_error, input paths, and output paths.
Claim jobs transactionally so two workers do not process the same record. In Postgres, the usual tool is a function like claim_next_pending_job with FOR UPDATE SKIP LOCKED:
create or replace function claim_next_pending_job(worker_id text)
returns jobs
language sql
as $$
update jobs
set
status = 'processing',
locked_by = worker_id,
locked_until = now() + interval '5 minutes',
attempts = attempts + 1
where id = (
select id
from jobs
where status = 'pending'
and (locked_until is null or locked_until < now())
order by created_at
FOR UPDATE SKIP LOCKED
limit 1
)
returning *;
$$;A minimal worker loop
The worker can be plain Node, Python, Go, or anything else that can talk to your database and APIs. The important move is to claim a job, write status after each meaningful step, and always end by releasing the lease.
const WORKER_ID = process.env.HOSTNAME ?? 'worker-1';
async function workOnce() {
const { data: job, error } = await supabase.rpc('claim_next_pending_job', {
worker_id: WORKER_ID,
});
if (error) throw error;
if (!job) return;
try {
const outputPath = await generateOutputFor(job);
await supabase
.from('jobs')
.update({
status: 'ready',
result_path: outputPath,
locked_until: null,
})
.eq('id', job.id);
} catch (error) {
await supabase
.from('jobs')
.update({
status: job.attempts >= 3 ? 'failed' : 'pending',
last_error: String(error),
locked_until: null,
})
.eq('id', job.id);
}
}Retries, leases, and idempotency
Treat every job as retryable. Give each claimed job a lease with locked_until so another worker can recover it if the process dies. Keep an attempts count, store last_error, and move exhausted work to failed or dead-letter status instead of looping forever.
Make outputs idempotent. Use stable storage paths based on the job id, avoid duplicate external charges where possible, and make the ready transition happen only after the generated files are already stored.
Run it with systemd
On a VPS, systemd is the standard way to keep a worker alive, restart it after deploys, and bring it back after a reboot. Put your app in a stable directory, keep secrets in an env file, and install a service like this:
[Unit]
Description=My app worker
After=network-online.target
[Service]
WorkingDirectory=/opt/my-app
EnvironmentFile=/opt/my-app/.env
ExecStart=/usr/bin/node worker.js
Restart=always
RestartSec=5
User=my-app
[Install]
WantedBy=multi-user.targetUse a dedicated Linux user and lock down the env file before starting the service:
sudo useradd --system --home /opt/my-app --shell /usr/sbin/nologin my-app
sudo chown -R my-app:my-app /opt/my-app
sudo chmod 600 /opt/my-app/.envThen reload systemd, enable the service, and follow logs:
sudo systemctl daemon-reload
sudo systemctl enable --now my-worker
journalctl -u my-worker -fProduction bar
- Keep secrets in server-only env files, not client code.
- Use a dedicated Linux user instead of root.
- Write progress to durable storage after every major step.
- Alert or inspect failed jobs instead of hiding them.
- Deploy with a repeatable script that restarts the service.
Where Provisionario fits
Provisionario does not need first-class support for every app-specific worker. Your VPS is a normal Linux server, so you can run a web app, a queue worker, a webhook processor, and a cron-like service side by side as your project needs them.
The product should make the server easy to get and operate. The app-specific architecture should stay yours: pick your database, queue, storage, job schema, and deploy script, then use the VPS as the stable machine that keeps the background process running.
What Provisionario handles
- Getting the VPS online.
- Giving you normal Linux server access.
- Making the server a stable place to run services.
What your app owns
- The database schema and job states.
- The worker code and external API calls.
- The deploy script, retry policy, and product behavior.