Published: Jul 2, 2025 by Isaac Johnson
I saw a Marious Post on ToCry which is a lightweight Kanban open-source app. It is elegant and quick and easy to setup. We’ll not only set it up and run in Docker and Kubernetes, but we’ll build out a full REST API for it as well.
In doing that, We’ll explore FastAPI and Typer. Both of these python frameworks are by Sebastián Ramírez who has a whole slew of excellent Opensource tools to explore.
We’ll start with this rather oddly named To-Do task app called “ToCry”
ToDo (ToCry)
I saw this MariusHosting post on “ToCry”, a todo app.
From the Github page, it comes from the question every developer asks “Are you going ToDo or ToCry?”
He suggested using portainer
services:
tocry:
image: ghcr.io/ralsina/tocry:latest
container_name: ToCry
ports:
- 3135:3000
environment:
TOCRY_AUTH_USER: marius
TOCRY_AUTH_PASS: mariushosting
volumes:
- /volume1/docker/tocry:/data:rw
restart: on-failure:5
I asked Co-Pilot for some help converting that to a Kubernetes manifest
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tocry-data-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tocry
spec:
replicas: 1
selector:
matchLabels:
app: tocry
template:
metadata:
labels:
app: tocry
spec:
containers:
- name: tocry
image: ghcr.io/ralsina/tocry:latest
ports:
- containerPort: 3000
env:
- name: TOCRY_AUTH_USER
value: "your_username"
- name: TOCRY_AUTH_PASS
value: "your_password"
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: tocry-data-pvc
---
apiVersion: v1
kind: Service
metadata:
name: tocry
spec:
selector:
app: tocry
ports:
- protocol: TCP
port: 3000
targetPort: 3000
I applied it
$ kubectl apply -f ./k8stocry.yaml
persistentvolumeclaim/tocry-data-pvc created
deployment.apps/tocry created
service/tocry created
I can now port-forward to test
$ kubectl port-forward svc/tocry 3000:3000
Forwarding from 127.0.0.1:3000 -> 3000
Forwarding from [::1]:3000 -> 3000
Once logged in, we can create our first board
We give it a name
Let’s add some swimlanes and a note:
I can mess with themes and light/dark mode but that is pretty much it
I could see from the network protocol it uses POST to add issues
Let’s try to use curl
to do it
curl -X POST http://localhost:3000/boards/myfirstboard/note \
-u your_username:your_password \
-H "Content-Type: application/json" \
-d '{
"lane_name": "Todo",
"note": {
"title": "My Note Title",
"tags": ["tag1", "tag2"],
"content": "This is the note content"
}
}'
I can now test with a curl
$ curl -X POST http://localhost:3000/boards/myfirstboard/note -u your_username:your_password -H "Content-Type: application/json" -d '{ "lane_name": "inbox", "note": { "title": "My Note Title", "tags": ["tag2","tag3"], "content": "my first test" }}'
{"sepia_id":"bc2767f7-44ab-4cab-84f5-98cb3c0476ca","id":"e01eee3d-a9f7-4133-8279-ad01d75788d3","title":"My Note Title","tags":["tag2","tag3"],"content":"my first test","expanded":false}
and see it created
Similarily we can use curl
to move issues
$ curl -u your_username:your_password -X PUT http://localhost:3000/boards/myfirstboard/note/e01eee3d-a9f7-4133-8279-ad01d75788d3 \
-H "Content-Type: application/json" \
-d '{
"note": {
"title": "My Note Title",
"tags": ["tag1", "tag2"],
"content": "This is the note content"
},
"lane_name": "blocked-review",
"position": 0
}'
and I can see it moved
Swagger
I wanted to add a Swagger API to this.
I used some Copilot with GPT-4.1
which added the swagger JSON
{
"openapi": "3.0.0",
"info": {
"title": "ToCry Kanban API",
"version": "1.0.0"
},
"paths": {
"/boards/{board_name}/lanes": {
"get": {
"summary": "Get all lanes for a board",
"parameters": [
{
"name": "board_name",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"responses": {
"200": {
"description": "List of lanes",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "type": "object" } }
}
}
}
}
}
},
"/boards/{board_name}/note": {
"post": {
"summary": "Add a note to a lane",
"parameters": [
{
"name": "board_name",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"lane_name": { "type": "string" },
"note": {
"type": "object",
"properties": {
"title": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"content": { "type": "string" }
},
"required": ["title"]
}
},
"required": ["lane_name", "note"]
}
}
}
},
"responses": {
"201": {
"description": "Note created",
"content": {
"application/json": {
"schema": { "type": "object" }
}
}
}
}
}
}
}
}
I tested with a POST
which made a Note
or get boards
I kept working the same query to add the rest of the possible RESTful interfaces
I also added handling of basic auth. I left in the code, but found because we have to auth to get to the API it is not required for local execute (but would if we wanted example code for local runs).
Then, after much testing and tweaking (often using Chromes network monitor to see the real REST call), I have a pretty good working Swagger page:
PRs and sharing
I now have some work that I might wish to share with others
builder@DESKTOP-QADGF36:~/Workspaces/tocry$ git status
On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: src/tocry.cr
Untracked files:
(use "git add <file>..." to include in what will be committed)
src/assets/swagger.json
src/endpoints/swagger.cr
no changes added to commit (use "git add" and/or "git commit -a")
I’ll fork to my own namespace
Then bring it down
builder@DESKTOP-QADGF36:~/Workspaces/tocry$ cd ..
builder@DESKTOP-QADGF36:~/Workspaces$ git clone https://github.com/idjohnson/tocry.git idjohnson-tocry
Cloning into 'idjohnson-tocry'...
remote: Enumerating objects: 1256, done.
remote: Counting objects: 100% (58/58), done.
remote: Compressing objects: 100% (45/45), done.
remote: Total 1256 (delta 13), reused 30 (delta 13), pack-reused 1198 (from 1)
Receiving objects: 100% (1256/1256), 856.30 KiB | 4.58 MiB/s, done.
Resolving deltas: 100% (877/877), done.
I’ll then copy over my changes
builder@DESKTOP-QADGF36:~/Workspaces$ cd idjohnson-tocry/
builder@DESKTOP-QADGF36:~/Workspaces/idjohnson-tocry$ cp ../tocry/src/assets/swagger.json ./src/assets/
builder@DESKTOP-QADGF36:~/Workspaces/idjohnson-tocry$ cp ../tocry/src/tocry.cr ./src/tocry.cr
builder@DESKTOP-QADGF36:~/Workspaces/idjohnson-tocry$ cp ../tocry/src/endpoints/swagger.cr ./src/endpoints/
builder@DESKTOP-QADGF36:~/Workspaces/idjohnson-tocry$ git status
On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: src/tocry.cr
Untracked files:
(use "git add <file>..." to include in what will be committed)
src/assets/swagger.json
src/endpoints/swagger.cr
no changes added to commit (use "git add" and/or "git commit -a")
I’ll commit my changes
builder@DESKTOP-QADGF36:~/Workspaces/idjohnson-tocry$ git add -A
builder@DESKTOP-QADGF36:~/Workspaces/idjohnson-tocry$ git commit -m "Offer an OpenAPI spec 3.0 Swagger Endpoint"
[main 3c423ae] Offer an OpenAPI spec 3.0 Swagger Endpoint
3 files changed, 483 insertions(+), 63 deletions(-)
create mode 100644 src/assets/swagger.json
create mode 100644 src/endpoints/swagger.cr
builder@DESKTOP-QADGF36:~/Workspaces/idjohnson-tocry$ git push
Enumerating objects: 13, done.
Counting objects: 100% (13/13), done.
Delta compression using up to 16 threads
Compressing objects: 100% (8/8), done.
Writing objects: 100% (8/8), 2.53 KiB | 2.53 MiB/s, done.
Total 8 (delta 5), reused 0 (delta 0)
remote: Resolving deltas: 100% (5/5), completed with 5 local objects.
To https://github.com/idjohnson/tocry.git
a577313..3c423ae main -> main
builder@DESKTOP-QADGF36:~/Workspaces
Lastly, I can create a PR back to their repo
I can then offer this as a PR should they want it.
For those that might just want my latest local built container from above, I did push to Docker hub
builder@DESKTOP-QADGF36:~/Workspaces/idjohnson-tocry$ docker tag tocry:local8 idjohnson/tocry:latest
builder@DESKTOP-QADGF36:~/Workspaces/idjohnson-tocry$ docker push idjohnson/tocry:latest
The push refers to repository [docker.io/idjohnson/tocry]
dff5a8c8e280: Pushed
latest: digest: sha256:97970f13a929b83bf8e557b4bcac3f3ea7e03b8d00dfd00d5bea6c83b68b8146 size: 528
I wrote a basic docker usage and made sure to credit the original author here.
For reference, I tend to run using
$ docker run --restart unless-stopped --name tocry -p 3000:3000 -e TOCRY_AUTH_USER=admin -e TOCRY_AUTH_PASS=password -v /home/builder/Workspaces/tocry/data:/data tocry:local8
FastAPI
While doing some searching for OpenAPI swagger tips, I found FastAPI. This is a framework to build quick python-based API systems with Swagger and RESTful interfaces.
We’ll make a Python virtual env
builder@DESKTOP-QADGF36:~/Workspaces$ mkdir fastapiProject
builder@DESKTOP-QADGF36:~/Workspaces$ cd fastapiProject/
builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$ python -m venv .venv
Then activate it and update pip
builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$ source .venv/bin/activate
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$ python -m pip install --upgrade pip
Requirement already satisfied: pip in ./.venv/lib/python3.11/site-packages (23.3.1)
Collecting pip
Downloading pip-25.1.1-py3-none-any.whl.metadata (3.6 kB)
Downloading pip-25.1.1-py3-none-any.whl (1.8 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.8/1.8 MB 16.0 MB/s eta 0:00:00
Installing collected packages: pip
Attempting uninstall: pip
Found existing installation: pip 23.3.1
Uninstalling pip-23.3.1:
Successfully uninstalled pip-23.3.1
Successfully installed pip-25.1.1
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$
I can now instll fastapi with pip install "fastapi[standard]"
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$ pip install "fastapi[standard]"
Collecting fastapi[standard]
Downloading fastapi-0.115.14-py3-none-any.whl.metadata (27 kB)
Collecting starlette<0.47.0,>=0.40.0 (from fastapi[standard])
Using cached starlette-0.46.2-py3-none-any.whl.metadata (6.2 kB)
Collecting pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4 (from fastapi[standard])
Downloading pydantic-2.11.7-py3-none-any.whl.metadata (67 kB)
Collecting typing-extensions>=4.8.0 (from fastapi[standard])
Downloading typing_extensions-4.14.0-py3-none-any.whl.metadata (3.0 kB)
Collecting fastapi-cli>=0.0.5 (from fastapi-cli[standard]>=0.0.5; extra == "standard"->fastapi[standard])
Downloading fastapi_cli-0.0.7-py3-none-any.whl.metadata (6.2 kB)
Collecting httpx>=0.23.0 (from fastapi[standard])
Using cached httpx-0.28.1-py3-none-any.whl.metadata (7.1 kB)
Collecting jinja2>=3.1.5 (from fastapi[standard])
Downloading jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB)
Collecting python-multipart>=0.0.18 (from fastapi[standard])
Downloading python_multipart-0.0.20-py3-none-any.whl.metadata (1.8 kB)
Collecting email-validator>=2.0.0 (from fastapi[standard])
Downloading email_validator-2.2.0-py3-none-any.whl.metadata (25 kB)
Collecting uvicorn>=0.12.0 (from uvicorn[standard]>=0.12.0; extra == "standard"->fastapi[standard])
Downloading uvicorn-0.35.0-py3-none-any.whl.metadata (6.5 kB)
Collecting annotated-types>=0.6.0 (from pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4->fastapi[standard])
Using cached annotated_types-0.7.0-py3-none-any.whl.metadata (15 kB)
Collecting pydantic-core==2.33.2 (from pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4->fastapi[standard])
Downloading pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (6.8 kB)
Collecting typing-inspection>=0.4.0 (from pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4->fastapi[standard])
Using cached typing_inspection-0.4.1-py3-none-any.whl.metadata (2.6 kB)
Collecting anyio<5,>=3.6.2 (from starlette<0.47.0,>=0.40.0->fastapi[standard])
Using cached anyio-4.9.0-py3-none-any.whl.metadata (4.7 kB)
Collecting idna>=2.8 (from anyio<5,>=3.6.2->starlette<0.47.0,>=0.40.0->fastapi[standard])
Using cached idna-3.10-py3-none-any.whl.metadata (10 kB)
Collecting sniffio>=1.1 (from anyio<5,>=3.6.2->starlette<0.47.0,>=0.40.0->fastapi[standard])
Using cached sniffio-1.3.1-py3-none-any.whl.metadata (3.9 kB)
Collecting dnspython>=2.0.0 (from email-validator>=2.0.0->fastapi[standard])
Using cached dnspython-2.7.0-py3-none-any.whl.metadata (5.8 kB)
Collecting typer>=0.12.3 (from fastapi-cli>=0.0.5->fastapi-cli[standard]>=0.0.5; extra == "standard"->fastapi[standard])
Downloading typer-0.16.0-py3-none-any.whl.metadata (15 kB)
Collecting rich-toolkit>=0.11.1 (from fastapi-cli>=0.0.5->fastapi-cli[standard]>=0.0.5; extra == "standard"->fastapi[standard])
Downloading rich_toolkit-0.14.8-py3-none-any.whl.metadata (999 bytes)
Collecting certifi (from httpx>=0.23.0->fastapi[standard])
Downloading certifi-2025.6.15-py3-none-any.whl.metadata (2.4 kB)
Collecting httpcore==1.* (from httpx>=0.23.0->fastapi[standard])
Using cached httpcore-1.0.9-py3-none-any.whl.metadata (21 kB)
Collecting h11>=0.16 (from httpcore==1.*->httpx>=0.23.0->fastapi[standard])
Using cached h11-0.16.0-py3-none-any.whl.metadata (8.3 kB)
Collecting MarkupSafe>=2.0 (from jinja2>=3.1.5->fastapi[standard])
Downloading MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.0 kB)
Collecting click>=8.1.7 (from rich-toolkit>=0.11.1->fastapi-cli>=0.0.5->fastapi-cli[standard]>=0.0.5; extra == "standard"->fastapi[standard])
Downloading click-8.2.1-py3-none-any.whl.metadata (2.5 kB)
Collecting rich>=13.7.1 (from rich-toolkit>=0.11.1->fastapi-cli>=0.0.5->fastapi-cli[standard]>=0.0.5; extra == "standard"->fastapi[standard])
Downloading rich-14.0.0-py3-none-any.whl.metadata (18 kB)
Collecting markdown-it-py>=2.2.0 (from rich>=13.7.1->rich-toolkit>=0.11.1->fastapi-cli>=0.0.5->fastapi-cli[standard]>=0.0.5; extra == "standard"->fastapi[standard])
Downloading markdown_it_py-3.0.0-py3-none-any.whl.metadata (6.9 kB)
Collecting pygments<3.0.0,>=2.13.0 (from rich>=13.7.1->rich-toolkit>=0.11.1->fastapi-cli>=0.0.5->fastapi-cli[standard]>=0.0.5; extra == "standard"->fastapi[standard])
Downloading pygments-2.19.2-py3-none-any.whl.metadata (2.5 kB)
Collecting mdurl~=0.1 (from markdown-it-py>=2.2.0->rich>=13.7.1->rich-toolkit>=0.11.1->fastapi-cli>=0.0.5->fastapi-cli[standard]>=0.0.5; extra == "standard"->fastapi[standard])
Downloading mdurl-0.1.2-py3-none-any.whl.metadata (1.6 kB)
Collecting shellingham>=1.3.0 (from typer>=0.12.3->fastapi-cli>=0.0.5->fastapi-cli[standard]>=0.0.5; extra == "standard"->fastapi[standard])
Downloading shellingham-1.5.4-py2.py3-none-any.whl.metadata (3.5 kB)
Collecting httptools>=0.6.3 (from uvicorn[standard]>=0.12.0; extra == "standard"->fastapi[standard])
Downloading httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (3.6 kB)
Collecting python-dotenv>=0.13 (from uvicorn[standard]>=0.12.0; extra == "standard"->fastapi[standard])
Downloading python_dotenv-1.1.1-py3-none-any.whl.metadata (24 kB)
Collecting pyyaml>=5.1 (from uvicorn[standard]>=0.12.0; extra == "standard"->fastapi[standard])
Using cached PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (2.1 kB)
Collecting uvloop>=0.15.1 (from uvicorn[standard]>=0.12.0; extra == "standard"->fastapi[standard])
Downloading uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.9 kB)
Collecting watchfiles>=0.13 (from uvicorn[standard]>=0.12.0; extra == "standard"->fastapi[standard])
Downloading watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.9 kB)
Collecting websockets>=10.4 (from uvicorn[standard]>=0.12.0; extra == "standard"->fastapi[standard])
Using cached websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (6.8 kB)
Downloading fastapi-0.115.14-py3-none-any.whl (95 kB)
Downloading pydantic-2.11.7-py3-none-any.whl (444 kB)
Downloading pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (2.0 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.0/2.0 MB 16.4 MB/s eta 0:00:00
Using cached starlette-0.46.2-py3-none-any.whl (72 kB)
Using cached anyio-4.9.0-py3-none-any.whl (100 kB)
Using cached annotated_types-0.7.0-py3-none-any.whl (13 kB)
Downloading email_validator-2.2.0-py3-none-any.whl (33 kB)
Using cached dnspython-2.7.0-py3-none-any.whl (313 kB)
Downloading fastapi_cli-0.0.7-py3-none-any.whl (10 kB)
Using cached httpx-0.28.1-py3-none-any.whl (73 kB)
Using cached httpcore-1.0.9-py3-none-any.whl (78 kB)
Using cached h11-0.16.0-py3-none-any.whl (37 kB)
Using cached idna-3.10-py3-none-any.whl (70 kB)
Downloading jinja2-3.1.6-py3-none-any.whl (134 kB)
Downloading MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (23 kB)
Downloading python_multipart-0.0.20-py3-none-any.whl (24 kB)
Downloading rich_toolkit-0.14.8-py3-none-any.whl (24 kB)
Downloading click-8.2.1-py3-none-any.whl (102 kB)
Downloading rich-14.0.0-py3-none-any.whl (243 kB)
Downloading pygments-2.19.2-py3-none-any.whl (1.2 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 19.0 MB/s eta 0:00:00
Downloading markdown_it_py-3.0.0-py3-none-any.whl (87 kB)
Downloading mdurl-0.1.2-py3-none-any.whl (10.0 kB)
Using cached sniffio-1.3.1-py3-none-any.whl (10 kB)
Downloading typer-0.16.0-py3-none-any.whl (46 kB)
Downloading shellingham-1.5.4-py2.py3-none-any.whl (9.8 kB)
Downloading typing_extensions-4.14.0-py3-none-any.whl (43 kB)
Using cached typing_inspection-0.4.1-py3-none-any.whl (14 kB)
Downloading uvicorn-0.35.0-py3-none-any.whl (66 kB)
Downloading httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl (459 kB)
Downloading python_dotenv-1.1.1-py3-none-any.whl (20 kB)
Using cached PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (762 kB)
Downloading uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (4.0 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.0/4.0 MB 16.1 MB/s eta 0:00:00
Downloading watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (453 kB)
Using cached websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl (182 kB)
Downloading certifi-2025.6.15-py3-none-any.whl (157 kB)
Installing collected packages: websockets, uvloop, typing-extensions, sniffio, shellingham, pyyaml, python-multipart, python-dotenv, pygments, mdurl, MarkupSafe, idna, httptools, h11, dnspython, click, certifi, annotated-types, uvicorn, typing-inspection, pydantic-core, markdown-it-py, jinja2, httpcore, email-validator, anyio, watchfiles, starlette, rich, pydantic, httpx, typer, rich-toolkit, fastapi, fastapi-cli
Successfully installed MarkupSafe-3.0.2 annotated-types-0.7.0 anyio-4.9.0 certifi-2025.6.15 click-8.2.1 dnspython-2.7.0 email-validator-2.2.0 fastapi-0.115.14 fastapi-cli-0.0.7 h11-0.16.0 httpcore-1.0.9 httptools-0.6.4 httpx-0.28.1 idna-3.10 jinja2-3.1.6 markdown-it-py-3.0.0 mdurl-0.1.2 pydantic-2.11.7 pydantic-core-2.33.2 pygments-2.19.2 python-dotenv-1.1.1 python-multipart-0.0.20 pyyaml-6.0.2 rich-14.0.0 rich-toolkit-0.14.8 shellingham-1.5.4 sniffio-1.3.1 starlette-0.46.2 typer-0.16.0 typing-extensions-4.14.0 typing-inspection-0.4.1 uvicorn-0.35.0 uvloop-0.21.0 watchfiles-1.1.0 websockets-15.0.1
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$
we can now make a small REST API demo
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$ vi main.py
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$ cat ./main.py
from typing import Union
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"Hello": "Fresh"}
@app.get("/items/{item_id}")
def read_item(item_id: int, q: Union[str, None] = None):
return {"item_id": item_id, "q": q}
I’ll now run it with fastapi dev main.py
The front door works dandy
The swagger was built for me
And I can try it out and get a response
and we can try some different values via the Swagger API
If Swagger isn’t your thing, there is Redoc as well exposed on “/redoc”
Though, unlike Swagger, Redoc doesn’t provide interactive “try it” buttons. It is just about good documentation
Dockerfile
Checking the Python I’m using
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$ python --version
Python 3.11.6
Suggests this should be easy to build into a docker container.
FROM python:3.11-slim
WORKDIR /app
COPY main.py .
RUN pip install --upgrade pip && \
pip install "fastapi[standard]"
EXPOSE 8000
CMD ["fastapi","dev", "main.py"]
I can build that
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$ docker build -t fastapi:test .
[+] Building 22.5s (10/10) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 210B 0.0s
=> [internal] load metadata for docker.io/library/python:3.11-slim 1.1s
=> [auth] library/python:pull token for registry-1.docker.io 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/4] FROM docker.io/library/python:3.11-slim@sha256:747b7782488dbf0a7c247b4da006097b871ae3a3fcd5d943c21b376eab6a6ef4 7.1s
=> => resolve docker.io/library/python:3.11-slim@sha256:747b7782488dbf0a7c247b4da006097b871ae3a3fcd5d943c21b376eab6a6ef4 0.1s
=> => sha256:747b7782488dbf0a7c247b4da006097b871ae3a3fcd5d943c21b376eab6a6ef4 9.13kB / 9.13kB 0.0s
=> => sha256:153bae509ec02c9ac789e2e35f3cbe94be446b59c3afcfbbc88c96a344d2eb73 1.75kB / 1.75kB 0.0s
=> => sha256:0b14a859cdba15104c5f194ef813fcccbf2749d74bc7be4550c06a0fc0d482e6 5.37kB / 5.37kB 0.0s
=> => sha256:3da95a905ed546f99c4564407923a681757d89651a388ec3f1f5e9bf5ed0b39d 28.23MB / 28.23MB 1.3s
=> => sha256:483d0dd375188d7d3b2d66116d5974d2b67e6988c6146eb2c6a3e2bc33a92121 3.51MB / 3.51MB 0.5s
=> => sha256:02a5d22e0d6f85a6ac1c7fb356e9eed39a981decd1fac1205a31f31f4cb010f1 16.21MB / 16.21MB 1.1s
=> => sha256:471797cdda8c4cd4a9795c8cb56078e627b3fc7486fd8574804ec7d5f1676b9b 249B / 249B 0.7s
=> => extracting sha256:3da95a905ed546f99c4564407923a681757d89651a388ec3f1f5e9bf5ed0b39d 3.2s
=> => extracting sha256:483d0dd375188d7d3b2d66116d5974d2b67e6988c6146eb2c6a3e2bc33a92121 0.3s
=> => extracting sha256:02a5d22e0d6f85a6ac1c7fb356e9eed39a981decd1fac1205a31f31f4cb010f1 1.9s
=> => extracting sha256:471797cdda8c4cd4a9795c8cb56078e627b3fc7486fd8574804ec7d5f1676b9b 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 297B 0.0s
=> [2/4] WORKDIR /app 0.4s
=> [3/4] COPY main.py . 0.0s
=> [4/4] RUN pip install --upgrade pip && pip install "fastapi[standard]" 12.8s
=> exporting to image 0.9s
=> => exporting layers 0.9s
=> => writing image sha256:1400413b555875e39b1a16c30b1f9ba0e7c9de5ff4b53332e6f5f030a6f7f12f 0.0s
=> => naming to docker.io/library/fastapi:test 0.0s
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$
I can now test
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/fastapiProject$ docker run --rm -p 8000:8000 fastapi:test
FastAPI Starting development server 🚀
Searching for package file structure from directories with
__init__.py files
Importing from /app
module 🐍 main.py
code Importing the FastAPI app object from the module with the following
code:
from main import app
app Using import string: main:app
server Server started at http://127.0.0.1:8000
server Documentation at http://127.0.0.1:8000/docs
tip Running in development mode, for production use: fastapi run
Logs:
INFO Will watch for changes in these directories: ['/app']
INFO Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO Started reloader process [1] using WatchFiles
INFO Started server process [8]
INFO Waiting for application startup.
INFO Application startup complete.
However, that did not work
I did fix pretty quick. Just use run
instead of dev
. for the CMD invokation.
That is:
FROM python:3.11-slim
WORKDIR /app
COPY main.py .
RUN pip install --upgrade pip && \
pip install "fastapi[standard]"
EXPOSE 8000
CMD ["fastapi","run", "main.py"]
Bluesky Poster
I got excited to add FastAPI to my BlueSky poster app. I wrote that with Flask, so it should be straightforward to swap out Flask with FastAPI
After some tweaks (like figuring out the proper way to use JSON payloads instead of QueryParameters), I got it to work
And we can see the results in Bluesky
And what a good time, while we are updating the chart, to reference Goldilocks to properly update the container specs
And as added to the updated chart
I’ll update and push to main
builder@DESKTOP-QADGF36:~/Workspaces/pybsposter$ git commit -m "new 0.1.0 version with FastAPI instead of Flask, new chart as well"
[main 81b382d] new 0.1.0 version with FastAPI instead of Flask, new chart as well
6 files changed, 52 insertions(+), 25 deletions(-)
builder@DESKTOP-QADGF36:~/Workspaces/pybsposter$ git push
Enumerating objects: 21, done.
Counting objects: 100% (21/21), done.
Delta compression using up to 16 threads
Compressing objects: 100% (9/9), done.
Writing objects: 100% (11/11), 1.40 KiB | 719.00 KiB/s, done.
Total 11 (delta 7), reused 0 (delta 0)
remote: Resolving deltas: 100% (7/7), completed with 7 local objects.
To https://github.com/idjohnson/pybsposter.git
e51072d..81b382d main -> main
The automated CICD build completed
So now I could upgrade my production cluster. It has been a while since I updated
$ helm list | grep pybsposter
pybsposter default 6 2025-02-06 08:14:25.12365721 -0600 CST deployed pybsposter-0.1.1 1.0
$ helm get values pybsposter
USER-SUPPLIED VALUES:
image:
tag: 0.0.5
ingress:
enabled: true
I did a quick dry-run first to ensure this would work (noting the image, chart version and memory/cpu setttings)
builder@DESKTOP-QADGF36:~/Workspaces/pybsposter$ helm upgrade --dry-run --debug pybsposter --set image.tag=0.1.2 --set ingress.enabled=true ./charts/pybsposter/
upgrade.go:164: 2025-07-01 12:21:30.519245608 -0500 CDT m=+0.266434903 [debug] preparing upgrade for pybsposter
upgrade.go:172: 2025-07-01 12:21:30.780895366 -0500 CDT m=+0.528084671 [debug] performing update for pybsposter
upgrade.go:366: 2025-07-01 12:21:30.887349751 -0500 CDT m=+0.634539056 [debug] dry run for pybsposter
Release "pybsposter" has been upgraded. Happy Helming!
NAME: pybsposter
LAST DEPLOYED: Tue Jul 1 12:21:30 2025
NAMESPACE: default
STATUS: pending-upgrade
REVISION: 7
TEST SUITE: None
USER-SUPPLIED VALUES:
image:
tag: 0.1.2
ingress:
enabled: true
COMPUTED VALUES:
image:
pullPolicy: IfNotPresent
repository: harbor.freshbrewed.science/library/pybsposter
tag: 0.1.2
ingress:
certManagerIssuer: gcpleprod2
clientMaxBodySize: "0"
connectTimeout: "3600"
enabled: true
host: bskyposter.steeped.space
ingressClass: nginx
proxyBodySize: "0"
readTimeout: "3600"
sendTimeout: "3600"
sslRedirect: "true"
tlsAcme: "true"
tlsSecretName: pybspostergcp-tls
replicas: 1
resources:
limits:
cpu: 15m
memory: 133Mi
requests:
cpu: 15m
memory: 105Mi
service:
port: 80
targetPort: 8000
type: ClusterIP
HOOKS:
MANIFEST:
---
# Source: pybsposter/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: pybsposter
labels:
helm.sh/chart: pybsposter-0.1.2
app.kubernetes.io/name: pybsposter
app.kubernetes.io/instance: pybsposter
app.kubernetes.io/version: "2.0"
app.kubernetes.io/managed-by: Helm
spec:
selector:
app.kubernetes.io/name: pybsposter
app.kubernetes.io/instance: pybsposter
type: ClusterIP
ports:
- protocol: TCP
port: 80
targetPort: 8000
---
# Source: pybsposter/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: pybsposter
labels:
helm.sh/chart: pybsposter-0.1.2
app.kubernetes.io/name: pybsposter
app.kubernetes.io/instance: pybsposter
app.kubernetes.io/version: "2.0"
app.kubernetes.io/managed-by: Helm
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: pybsposter
app.kubernetes.io/instance: pybsposter
template:
metadata:
labels:
app.kubernetes.io/name: pybsposter
app.kubernetes.io/instance: pybsposter
spec:
containers:
- name: pybsposter
image: "harbor.freshbrewed.science/library/pybsposter:0.1.2"
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8000
resources:
limits:
cpu: 15m
memory: 133Mi
requests:
cpu: 15m
memory: 105Mi
---
# Source: pybsposter/templates/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pybsposter
labels:
helm.sh/chart: pybsposter-0.1.2
app.kubernetes.io/name: pybsposter
app.kubernetes.io/instance: pybsposter
app.kubernetes.io/version: "2.0"
app.kubernetes.io/managed-by: Helm
annotations:
cert-manager.io/cluster-issuer: "gcpleprod2"
ingress.kubernetes.io/proxy-body-size: "0"
ingress.kubernetes.io/ssl-redirect: "true"
kubernetes.io/ingress.class: "nginx"
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.org/client-max-body-size: "0"
nginx.org/proxy-connect-timeout: "3600"
nginx.org/proxy-read-timeout: "3600"
nginx.org/websocket-services: pybsposter
spec:
rules:
- host: bskyposter.steeped.space
http:
paths:
- backend:
service:
name: pybsposter
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- bskyposter.steeped.space
secretName: pybspostergcp-tls
Looks good, so let’s upgrade
builder@DESKTOP-QADGF36:~/Workspaces/pybsposter$ helm upgrade pybsposter --set image.tag=0.1.2 --set ingress.enabled=true ./charts/pybsposter/
Release "pybsposter" has been upgraded. Happy Helming!
NAME: pybsposter
LAST DEPLOYED: Tue Jul 1 12:22:22 2025
NAMESPACE: default
STATUS: deployed
REVISION: 7
TEST SUITE: None
However, I saw an error
builder@DESKTOP-QADGF36:~/Workspaces/pybsposter$ kubectl get po -l app.kubernetes.io/instance=pybsposter
NAME READY STATUS RESTARTS AGE
pybsposter-697b79dc87-j46mj 0/1 CreateContainerError 0 69s
pybsposter-7d4dfbcddb-gz9kv 1/1 Running 1 (11d ago) 144d
builder@DESKTOP-QADGF36:~/Workspaces/pybsposter$ kubectl describe po pybsposter-697b79dc87-j46mj | tail -n 15
node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 77s default-scheduler Successfully assigned default/pybsposter-697b79dc87-j46mj to builder-hp-elitebook-745-g5
Normal Pulling 71s kubelet Pulling image "harbor.freshbrewed.science/library/pybsposter:0.1.2"
Normal Pulled 70s kubelet Successfully pulled image "harbor.freshbrewed.science/library/pybsposter:0.1.2" in 999ms (999ms including waiting)
Warning Failed 70s kubelet Error: failed to generate container "2d4d07a38ddd7c1835c76c58be29524754bedb6f26b54fdb95620a17022f4f0c" spec: failed to generate spec: no command specified
Warning Failed 70s kubelet Error: failed to generate container "8264aaf09d8da45aea2afe02fa18604b54cdf082e2d712b8fa02bfcd3b81eb8d" spec: failed to generate spec: no command specified
Warning Failed 58s kubelet Error: failed to generate container "c231f752ae7c76eb7bddab9dae9de68ae9516f9f5525d1fd54af079a5abb0425" spec: failed to generate spec: no command specified
Warning Failed 45s kubelet Error: failed to generate container "6e9c950585cd3974a5773aecb6bdf407744799ba2c1bf240b0f6d7f870fd4f3a" spec: failed to generate spec: no command specified
Warning Failed 30s kubelet Error: failed to generate container "b9afd7dda6313672d151c19d91113df43c5e0299318eda8865c4e1310ffb0c45" spec: failed to generate spec: no command specified
Warning Failed 19s kubelet Error: failed to generate container "50ce21ffd7ae3d11305b6ce44a0e02c2d811d29c7a41528dc5a03f05a703e216" spec: failed to generate spec: no command specified
Normal Pulled 4s (x6 over 70s) kubelet Container image "harbor.freshbrewed.science/library/pybsposter:0.1.2" already present on machine
Warning Failed 4s kubelet Error: failed to generate container "f75e52d2605ce34011ba36e3697bf2be704c7e962c9be848b1ef784425e39f1a" spec: failed to generate spec: no command specified
That is entirely my error as the container should have been 0.1.0
not 0.1.2
(that is the chart)
I’ll fix that real quick by changing the image tag
$ helm upgrade pybsposter --set image.tag=0.1.0 --set ingress.enabled=true ./charts/pybsposter/
Release "pybsposter" has been upgraded. Happy Helming!
NAME: pybsposter
LAST DEPLOYED: Tue Jul 1 12:26:45 2025
NAMESPACE: default
STATUS: deployed
REVISION: 8
TEST SUITE: None
That looks much better
$ kubectl get po -l app.kubernetes.io/instance=pybsposter
NAME READY STATUS RESTARTS AGE
pybsposter-69f8d7cc69-b2p7v 1/1 Running 0 21s
pybsposter-7d4dfbcddb-gz9kv 1/1 Terminating 1 (11d ago) 144d
However, while the pod is happy, the ingress is not
I did some fighting, and it turns out Goldilocks was a bit too reduced on the container specs. It was failing to really load until I upped the values
resources:
requests:
cpu: 15m
memory: 105Mi
limits:
cpu: 250m
memory: 250Mi
Now I can see the Swagger
as well as Redoc
I then realized my existing workflows used capitalized fields like “UNSERNAME” and “PASSWORD” which failed.
I fixed the code
class SocialPost(BaseModel):
username: str
password: str
text: str
link: str | None = None
class Config:
allow_population_by_field_name = True
alias_generator = lambda s: s.upper()
populate_by_name = True
then pushed it up
builder@LuiGi:~/Workspaces/pybsposter$ git commit -m "update to accept uppercase as the old poster did"
[main 83fa4f2] update to accept uppercase as the old poster did
2 files changed, 6 insertions(+), 1 deletion(-)
builder@LuiGi:~/Workspaces/pybsposter$ git push
Enumerating objects: 7, done.
Counting objects: 100% (7/7), done.
Delta compression using up to 16 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (4/4), 485 bytes | 485.00 KiB/s, done.
Total 4 (delta 3), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (3/3), completed with 3 local objects.
To https://github.com/idjohnson/pybsposter.git
192af1c..83fa4f2 main -> main
Which made a new 0.1.1 image
I can nupgrade with helm
$ helm upgrade pybsposter --set image.tag=0.1.1 --set ingress.enabled=true ./charts/pybsposter/
Release "pybsposter" has been upgraded. Happy Helming!
NAME: pybsposter
LAST DEPLOYED: Tue Jul 1 17:52:33 2025
NAMESPACE: default
STATUS: deployed
REVISION: 11
TEST SUITE: None
and a quick test works
Typer
They have another project for making CLI projects, Typer.
Our setup is similar to FastAPI
(.venv) builder@DESKTOP-QADGF36:~/Workspaces$ mkdir typerProject
(.venv) builder@DESKTOP-QADGF36:~/Workspaces$ cd typerProject/
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/typerProject$ python -m venv .venv
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/typerProject$ source .venv/bin/activate
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/typerProject$ python -m pip install --upgrade pip
Requirement already satisfied: pip in ./.venv/lib/python3.11/site-packages (23.3.1)
Collecting pip
Using cached pip-25.1.1-py3-none-any.whl.metadata (3.6 kB)
Using cached pip-25.1.1-py3-none-any.whl (1.8 MB)
Installing collected packages: pip
Attempting uninstall: pip
Found existing installation: pip 23.3.1
Uninstalling pip-23.3.1:
Successfully uninstalled pip-23.3.1
Successfully installed pip-25.1.1
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/typerProject$ pip install typer
Collecting typer
Using cached typer-0.16.0-py3-none-any.whl.metadata (15 kB)
Collecting click>=8.0.0 (from typer)
Using cached click-8.2.1-py3-none-any.whl.metadata (2.5 kB)
Collecting typing-extensions>=3.7.4.3 (from typer)
Using cached typing_extensions-4.14.0-py3-none-any.whl.metadata (3.0 kB)
Collecting shellingham>=1.3.0 (from typer)
Using cached shellingham-1.5.4-py2.py3-none-any.whl.metadata (3.5 kB)
Collecting rich>=10.11.0 (from typer)
Using cached rich-14.0.0-py3-none-any.whl.metadata (18 kB)
Collecting markdown-it-py>=2.2.0 (from rich>=10.11.0->typer)
Using cached markdown_it_py-3.0.0-py3-none-any.whl.metadata (6.9 kB)
Collecting pygments<3.0.0,>=2.13.0 (from rich>=10.11.0->typer)
Using cached pygments-2.19.2-py3-none-any.whl.metadata (2.5 kB)
Collecting mdurl~=0.1 (from markdown-it-py>=2.2.0->rich>=10.11.0->typer)
Using cached mdurl-0.1.2-py3-none-any.whl.metadata (1.6 kB)
Using cached typer-0.16.0-py3-none-any.whl (46 kB)
Using cached click-8.2.1-py3-none-any.whl (102 kB)
Using cached rich-14.0.0-py3-none-any.whl (243 kB)
Using cached pygments-2.19.2-py3-none-any.whl (1.2 MB)
Using cached markdown_it_py-3.0.0-py3-none-any.whl (87 kB)
Using cached mdurl-0.1.2-py3-none-any.whl (10.0 kB)
Using cached shellingham-1.5.4-py2.py3-none-any.whl (9.8 kB)
Using cached typing_extensions-4.14.0-py3-none-any.whl (43 kB)
Installing collected packages: typing-extensions, shellingham, pygments, mdurl, click, markdown-it-py, rich, typer
Successfully installed click-8.2.1 markdown-it-py-3.0.0 mdurl-0.1.2 pygments-2.19.2 rich-14.0.0 shellingham-1.5.4 typer-0.16.0 typing-extensions-4.14.0
We’ll make the basic Hello World example
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/typerProject$ vi main.py
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/typerProject$ cat main.py
def main(name: str):
print(f"Hello {name}")
We can now see an example error, help and actual usage:
We can also just import typer to run in python
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/typerProject$ cat main.py
import typer
def main(name: str):
print(f"Greetings {name}")
def main(name: str, mood: str):
print(f"Hi {name}, sounds like you are feeling {mood}")
if __name__ == "__main__":
typer.run(main)
you’ll note that mood was set to required. But we an use defaults as well to make those optional
(.venv) builder@DESKTOP-QADGF36:~/Workspaces/typerProject$ cat main.py
import typer
app = typer.Typer()
@app.command()
def main(name: str, mood: str = "jolly"):
print(f"Hi {name}, sounds like you are feeling {mood}")
if __name__ == "__main__":
typer.run(main)
Summary
Today we looked into ToCry and set it up in Docker and Kubernetes. I really liked it and wanted to consider using it with a pipeline which necessitated API access. This prompted me to build out a Swagger (OpenAPI) backend and share it back with the author.
In doing that, I came across FastAPI which makes it easy to add automatically created Swagger (and Redoc) APIs around python apps. After doing the demo code, I pulled down my Bluesky Poster app (pybsposter) and moved it from Flask to FastAPI. This was pretty easy and now is running at https://bskyposter.steeped.space/ with a new swagger interface in /docs.
I also looked into some other OS tools from Sebastián Ramírez such as Typer which does for a CLI what FastAPI did for Web APIs.