By the end of this guide a Python script that calls an AI model will run by itself every morning, on a machine you do not own, patch or pay a monthly fee for. The whole setup is one YAML file of about thirty lines plus a secret you paste into a settings screen, and it takes roughly twenty minutes the first time. You will also be able to trigger the same job by hand from a button in the browser, which is what makes it testable.
This is one guide in Deploying Python AI Apps, the section about getting a working script out of your laptop and into somewhere it keeps running. If your script needs to answer requests the instant they arrive rather than run on a clock, Turn a Python AI Script into an API with FastAPI is the better starting point.
Prerequisites
You need a GitHub account, a repository you can push to, and a Python script that already works when you run it on your own machine. Everything here assumes Python 3.10 or newer; if you have not set up a project folder with an isolated set of packages yet, Create a Python Virtual Environment for AI covers that in a few minutes.
Locally, install what your script needs:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "python-dotenv>=1.0"
While you develop, keep your key in a .env file — a plain text file of NAME=value lines that never leaves your machine:
OPENAI_API_KEY=sk-your-key-here
Add .env to .gitignore before your first commit, because a key pushed to a public repository can be found and spent by strangers within minutes:
echo ".env" >> .gitignore
The scheduled run will not use that file at all. On GitHub the same value arrives as an environment variable, which is why the script below reads the key from the environment rather than from the file directly.
Step 1 — Put the script in a repository with pinned dependencies
A scheduled run starts from nothing: a blank virtual machine that has never seen your project. It gets your code by cloning the repository and your packages by reading a list. If that list is missing or vague, the job either fails or silently installs a newer library than the one you tested against.
Write the list with exact versions:
pip freeze > requirements.txt
Open the file and check it. pip freeze records everything in your environment, so if you installed extras for experiments, delete the lines you do not need. What you want is a short file where every line carries == and a version number, like openai==1.40.0. That double equals sign is the pin: it tells the remote machine to install that exact release, so tomorrow's run behaves like today's.
Now the script itself. This one asks a model for a short briefing and writes it to a dated file:
import os
from datetime import date
from pathlib import Path
from openai import OpenAI
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise SystemExit("OPENAI_API_KEY is not set — add it as a repository secret.")
client = OpenAI(api_key=api_key)
def main() -> None:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You write short, concrete briefings."},
{"role": "user", "content": "Give me three bullet points on AI tooling for a small business owner today."},
],
)
text = response.choices[0].message.content
output_dir = Path("output")
output_dir.mkdir(exist_ok=True)
report = output_dir / f"{date.today().isoformat()}.md"
report.write_text(text, encoding="utf-8")
print(f"Wrote {report} ({response.usage.total_tokens} tokens)")
if __name__ == "__main__":
main()
Two details matter for scheduling. The script reads OPENAI_API_KEY from the environment and stops with a clear message when it is missing, so a misconfigured secret produces one readable line instead of a confusing traceback. And it writes into an output/ folder it creates itself, because the runner's disk starts empty. Save the file as daily_digest.py, commit both files, and push.
Step 2 — Store the API key as a repository secret
In your repository, open Settings, then Secrets and variables, then Actions, and choose New repository secret. Name it OPENAI_API_KEY and paste the value. GitHub encrypts it, never shows it again, and replaces it with asterisks anywhere it appears in a log. You will refer to it in the workflow as ${{ secrets.OPENAI_API_KEY }} — that expression is a placeholder GitHub substitutes at run time, so the real value never appears in a file you commit.
The rule underneath is worth understanding rather than memorising: secrets are handed to the run, not to the code. A workflow triggered by a pull request from someone else's fork of your repository deliberately receives nothing. If GitHub did not withhold them, any stranger could open a pull request whose workflow prints your key to a public log and reads it a minute later. That protection only holds if you never write the key into a tracked file and never print() it yourself.
Secrets are also the right place for anything else the script needs — a database password, a webhook address, a mail server login. Add one secret per value and keep the names identical to the environment variable names your code reads. For the wider picture of where credentials live once a project grows past one script, read Manage API Keys Safely in Production.
Step 3 — Write the workflow file
Workflows live at .github/workflows/ inside your repository, and the folder name is exact — a typo there means GitHub never notices the file. Create .github/workflows/daily-digest.yml:
name: Daily AI digest
on:
schedule:
- cron: "0 7 * * *" # 07:00 UTC, every day
workflow_dispatch: # adds a "Run workflow" button
jobs:
digest:
runs-on: ubuntu-latest
steps:
- name: Get the code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run the digest script
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: python daily_digest.py
- name: Upload the report
uses: actions/upload-artifact@v4
with:
name: daily-report
path: output/
Read it top to bottom. The on: block lists two triggers. schedule is the clock, and workflow_dispatch puts a Run workflow button on the Actions tab so you can fire the same job on demand — add it to every scheduled workflow you write, because otherwise your only way to test a change is to wait until tomorrow morning. runs-on: ubuntu-latest asks for a fresh Linux machine. The uses: lines pull in ready-made building blocks that GitHub maintains: one clones your repository, one installs a specific Python version, one saves files after the job.
The env: block on the run step is where the secret becomes an environment variable, and it is scoped to that single step. Nothing else in the workflow can see it, and nothing in the YAML contains the value itself.
Commit the workflow file and push it. Open the Actions tab, pick Daily AI digest in the sidebar, and press Run workflow. Watch the log expand step by step. A green tick on the manual run is the only proof worth having that tomorrow's scheduled run will work.
Step 4 — Keep the output before the machine disappears
The runner is destroyed as soon as the job ends. Files your script wrote are gone unless you deliberately move them somewhere permanent, and this catches almost everyone once. There are two straightforward destinations.
An artifact is a zip file GitHub attaches to the run, downloadable from the run's summary page. That is the actions/upload-artifact@v4 step already in the workflow above. Artifacts are ideal for things you look at occasionally and expire after a retention period you can set in the repository settings.
Committing the file back into the repository suits output you want to accumulate and browse — a folder of dated reports, a CSV that grows a row per day. It needs one extra permission and one extra step:
permissions:
contents: write
jobs:
digest:
steps:
# ... after the step that runs the script:
- name: Commit the report
run: |
git config user.name "github-actions"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add output/
git diff --staged --quiet || git commit -m "Add daily report"
git push
Put the permissions: block at the top level of the file, beside on: and jobs:. The git diff --staged --quiet || part is the piece people leave out: without it, a day when the output happens to be identical produces an empty commit, git commit exits with an error, and the whole run turns red for no reason. Reading it aloud helps — "if nothing is staged, skip the commit".
A third option is to send the result somewhere else entirely: post it to Slack, email it, or write it to a database. Anything your script can do locally, it can do from the runner, as long as the credentials come from secrets.
Cron expression quick reference
The cron value is five fields separated by spaces: minute, hour, day of month, month, day of week. An asterisk means "every". All times are UTC, with no daylight-saving adjustment ever.
| Cron expression | When it runs |
|---|---|
0 7 * * * | Every day at 07:00 UTC |
30 6 * * 1 | Every Monday at 06:30 UTC |
0 */6 * * * | Every six hours, on the hour |
0 9 1 * * | The first of each month at 09:00 UTC |
Two habits save time here. Write the UTC offset of your own time zone on a sticky note before you pick an hour, and avoid 0 in the minute field: the top of the hour is when everyone else schedules, so it is the slowest moment to queue. 17 7 * * * starts sooner in practice than 0 7 * * *.
The limits worth knowing before you rely on this
Scheduled runs are queued, not guaranteed. GitHub starts them when capacity allows, which usually means within a few minutes and occasionally means later. If your job must fire at exactly 07:00:00, this is the wrong tool. If "some time shortly after seven" is fine — and for a morning digest, a report, or a data pull, it usually is — the delay never matters.
Schedules also go quiet on their own. A repository with no commits for sixty days has its scheduled workflows disabled automatically, and GitHub emails you when that happens. Since your job may be the only thing touching the repository, and its own commits do not count as activity for this purpose, put a calendar reminder to push something occasionally, or accept that you will re-enable it from the Actions tab now and then.
Then there are minutes. Runners are billed by the minute of wall-clock time. Public repositories use standard runners at no charge; private ones come with a monthly allowance included in your plan and charge per minute beyond it. A two-minute daily job is a rounding error, but a job that runs every five minutes and installs a large machine-learning stack each time is not — that is sixty runs a day, each paying the install cost again. Check the usage figures on your own account rather than trusting any number you read elsewhere, including this page.
The cost that actually surprises people is the API bill, not the runner. A script that quietly loops over five hundred rows, running unattended every morning, spends real money while you are asleep. Put a ceiling on it before you switch the schedule on: Set a Monthly AI API Spending Limit walks through the provider-side cap, and Log and Monitor AI API Calls in Production shows how to record what each run consumed so a change in behaviour is visible.
Troubleshooting
Invalid workflow file: You have an error in your yaml syntax — the file is not valid YAML, and nine times out of ten the cause is indentation. YAML forbids tab characters entirely and cares that sibling keys line up in the same column. Fix: set your editor to insert spaces, then re-indent the block the error line points at so every list item under steps: starts at the same column.
OPENAI_API_KEY is not set — add it as a repository secret — your own guard clause fired, meaning the step ran without the variable. Either the secret name in Settings does not match the name used inside secrets. in the workflow exactly, including case, or the env: block sits under the wrong step. Fix: compare the two names character by character, and confirm env: is indented under the same step as the run: that calls Python.
ModuleNotFoundError: No module named 'openai' — the install step did not put that package on the runner. Usually requirements.txt is missing from the repository, sits in a subfolder, or was generated from an environment that never had the package. Fix: run pip freeze > requirements.txt inside the activated virtual environment, confirm the openai== line is there, and commit it. Fix ModuleNotFoundError: No Module Named openai covers the local version of this error.
remote: Permission to your-repo.git denied to github-actions[bot] — the commit-back step tried to push without write access. Fix: add the top-level permissions: block with contents: write shown in Step 4, and make sure it is not nested inside jobs:.
When a failure produces a long Python traceback rather than one of these, open the failed step in the log and read the last lines first; Read a Python Traceback in Five Minutes explains how to pick the useful line out of the noise. Rate-limit failures deserve special care in an unattended job, since nobody is watching to retry — Fix the 429 Rate-Limit Error in Python shows the retry loop to add.
When to use this vs. alternatives
- Use GitHub Actions when the job runs on a clock, finishes in minutes, and a few minutes of drift is acceptable. You get versioned configuration, a log of every past run, encrypted secrets and zero machines to patch — that covers most daily reports, weekly digests and overnight data pulls.
- Use a plain cron job on a small VPS when timing must be precise, when the job runs so often that per-minute billing stops being cheap, or when it needs a large model file or a long-lived local database that would take minutes to fetch on every fresh runner. The setup is one line in
crontab -esuch as0 7 * * * /home/you/app/.venv/bin/python /home/you/app/daily_digest.py, and the price is that patching, backups and disk space become yours. - Use a web API instead of a schedule when something outside your control decides when the work happens — a form submission, a webhook, a user pressing a button. A scheduled job that polls every five minutes for work that might not exist is a slow, expensive way to build what a request-driven service does instantly.
Start with the schedule; you can move the same script to a server later without changing a line of it, because it reads its configuration from the environment either way. If your first scheduled job is a recurring summary, Automate a Weekly Report with Python and AI is a good script to point this workflow at.
Back to Deploying Python AI Apps.
Related guides
- Turn a Python AI Script into an API with FastAPI — for work that has to happen on request rather than on a clock.
- Manage API Keys Safely in Production — where credentials belong once you have more than one of them.
- Log and Monitor AI API Calls in Production — see what an unattended run actually did and spent.
- Set a Monthly AI API Spending Limit — put a ceiling on a job that runs while you sleep.
- Automate a Weekly Report with Python and AI — a ready-made script worth putting on this schedule.