Deploying to Miget from a private GHCR: an AI agent did the whole thing

Hello there!
A small disclaimer before we start: Miget was built by my friend. It's a PaaS for deploying containerized apps. I wanted to try it on my own project for some time, so I did, and here I want to share how it went.
To be honest, I didn't expect it to be this easy. The REST API is so clean that an AI agent did the whole deploy on its own, from zero to a running stack. It hit a few problems on the way and solved them by itself. Let me show you how it looked. Ready? Let's go!
What this was
The goal was a "walking skeleton" - prove the whole deploy chain before writing a single feature. Two container images from GitHub Container Registry (GHCR): a Spring Boot API and a Next.js web frontend. Pull them onto Miget, point the API at PostgreSQL, and get GET /health to return UP.
That's it. No features. No real users. Just proof that the whole chain works: GHCR pulls from a private repo, containers start, Flyway migrates the real database, HTTP responds.
The repo is private, so the GHCR packages are private too. That adds a few extra steps compared to a public image.
How the agent drove it
I started by reading the Miget REST API myself - checked the OpenAPI spec at https://app.miget.com/docs/openapi.json, fired a few read-only curl calls to see what's there. Then I installed Miget's own agent skill:
npx skills add migetapp/agent-skills
That gives you a miget-api skill with a SKILL.md that has the exact schemas. After that I handed the task to the agent: find the account, create the registry credential, create the apps with the credential attached, set the env vars, deploy, poll for state, hit the health endpoint.
The agent ran the full sequence. I watched. It hit walls. It worked around them. I did not intervene. That's the part worth writing about.
Setup / prerequisites
Miget API token from
https://app.miget.com/my_account#api_tokens. All requests go to basehttps://app.miget.com/api/v1with headerAuthorization: Bearer <MIGET_TOKEN>.Images on GHCR. The CI (
container.yml) only pushes onpushtomain, and it tags by commit sha -sha-<commit>. The feature branch had to be merged tomainfirst to getghcr.io/<owner>/<repo>/{api,web}:sha-<commit>. Deploy by the sha tag, notlatest.Miget agent skill (optional but useful):
npx skills add migetapp/agent-skills. I read the API first, then installed this for the exact request/response schemas.
Step 0 - Discover the account
The agent started read-only. No writes until we knew what we were working with.
B=https://app.miget.com/api/v1 ; T=<MIGET_TOKEN>
curl -s -H "Authorization: Bearer \(T" "\)B/projects/<PROJECT>" # confirm token + project
curl -s -H "Authorization: Bearer \(T" "\)B/resources" # compute node
curl -s -H "Authorization: Bearer \(T" "\)B/services" # existing Postgres service
curl -s -H "Authorization: Bearer \(T" "\)B/apps" # existing apps
What came back shaped everything that followed:
Project was empty - zero apps.
One compute node
migetrmb: 1 GB RAM total, about 256 MB already used by the Postgres service. That leaves roughly 768 MB free.An existing standalone Postgres service (
postgres-service-d7eqa), statehealthy.GET /services/{id}returnsconnection_detailswith both an internal host (.migetapp.internal- for apps on the same node) and an external host (.onmiget.com- reachable from a laptop), plus the database name, user, password, and a ready-to-runpsqlcommand.
RAM budget noted. Internal vs external host noted. Moving on.
Step 1 - First deploy attempt (and the first failure)
The agent created both apps with the container_registry deployment method, set env vars, and deployed.
curl -s -X POST "\(B/apps" -H "Authorization: Bearer \)T" -H 'Content-Type: application/json' -d '{
"name":"inbox-api","label":"Inbox API","project_id":"<PROJECT>","resource_id":"<NODE>",
"builder":"dockerfile","ram_size":512,"cpu_size":0.5,
"deployment_method":"container_registry",
"deployment_config":{"image_url":"ghcr.io/<owner>/<repo>/api","tag":"sha-<commit>"}}'
curl -s -X POST "\(B/apps/<API_APP>/deploy" -H "Authorization: Bearer \)T" -d '{}'
Result: state: failed in about 11 seconds. logs_stored_at: null.
The agent read this correctly. For a container_registry deploy, a fast failure with no stored logs means the container never started - which means the image pull failed. The repo is private. No registry credential was attached. Miget couldn't authenticate to GHCR.
Fast fail, no logs - pull problem, not a runtime problem. That's the pattern to remember.
Step 2 - Create a registry credential
To pull from a private GHCR repo you need a GitHub PAT with read:packages. Then you register it as a Miget credential. The endpoint is in the OpenAPI spec, not in the basic guide:
curl -s -X POST "$B/container_registry_credentials" \
-H "Authorization: Bearer $T" \
-H 'Content-Type: application/json' \
-d '{"name":"ghcr-inbox","registry":"github","username":"<github-user>","token":"<GHCR_PAT>"}'
# → { "uuid": "<CRED_UUID>" }
Miget validates the credential live on create. If the PAT is wrong, you hear about it immediately.
The registry field accepts: docker_hub, github, gitlab, aws_ecr, azure, digitalocean, quay, generic.
Step 3 - The credential_id quirk (delete and recreate)
With the credential UUID in hand, the agent tried to attach it to the existing apps via PUT /apps/{uuid}. HTTP 200 came back. But the credential was silently dropped.
Reading the OpenAPI more carefully: credential_id lives inside deployment_config and is only accepted at app creation (POST /apps). It is not accepted on PUT. It is not accepted on the deploy call. There is no way to add it after creation.
So the agent deleted both apps and recreated them with credential_id in deployment_config from the start.
curl -s -X DELETE "\(B/apps/<OLD_APP>" -H "Authorization: Bearer \)T"
curl -s -X POST "\(B/apps" -H "Authorization: Bearer \)T" -H 'Content-Type: application/json' -d '{
"name":"inbox-api","label":"Inbox API","project_id":"<PROJECT>","resource_id":"<NODE>",
"builder":"dockerfile","ram_size":512,"cpu_size":0.5,
"deployment_method":"container_registry",
"deployment_config":{
"type":"container_registry",
"image_url":"ghcr.io/<owner>/<repo>/api",
"tag":"sha-<commit>",
"credential_id":"<CRED_UUID>"
}}'
One more thing: the API does not echo credential_id back in GET or POST responses. After creation, the app resource looks like the field was never set. But it was applied - the private pull succeeds. You just can't confirm it from the API response alone. The web UI is more helpful here: it shows a dropdown ("Select a credential…" with "Leave empty if your image is publicly accessible"), so you can check visually.
Step 4 - Port 5000 and the database
Port 5000. Miget serves every app's public *.migetapp.com URL from container port 5000. Fixed. The Spring Boot image listens on 8080 and the Next.js image on 3000. The fix is an env var:
Spring Boot:
SERVER_PORT=5000Next.js:
PORT=5000
Database - CREATEDB denied. The agent checked whether the Postgres service user could create a new database (inbox). It cannot:
PGPASSWORD=<pw> psql -h <svc>.db.<region>.onmiget.com -U <user> <defaultdb> \
-c "CREATE DATABASE inbox;"
# → ERROR: permission denied to create database
The workaround: reuse the default database that already exists and isolate with Flyway schemas (shared, inbox, ai, crm). Good enough for the skeleton.
The env vars for the API app, set one-per-key via POST /apps/<API_APP>/vars:
SPRING_PROFILES_ACTIVE = prod
DATABASE_URL = jdbc:postgresql://<svc>.<node>.<region>.migetapp.internal:5432/<defaultdb>
SPRING_DATASOURCE_USERNAME = <user from connection_details.internal>
SPRING_DATASOURCE_PASSWORD = <password from connection_details.internal>
SERVER_PORT = 5000
For the web app:
PORT = 5000
One note on SPRING_PROFILES_ACTIVE: the Spring image bakes no profile or credentials and defaults to a local profile pointing at localhost. Without SPRING_PROFILES_ACTIVE=prod, the container starts fine but silently ignores DATABASE_URL. That env var is not optional.
Step 5 - Deploy and verify
curl -s -X POST "\(B/apps/<API_APP>/deploy" -H "Authorization: Bearer \)T" -d '{}'
curl -s -X POST "\(B/apps/<WEB_APP>/deploy" -H "Authorization: Bearer \)T" -d '{}'
Poll for state:
# app-level state
curl -s -H "Authorization: Bearer \(T" "\)B/apps/<API_APP>" # → state: running
# deployment-level state
curl -s -H "Authorization: Bearer \(T" "\)B/apps/<API_APP>/deployments" # → state: completed
Public URLs follow the pattern https://<app-name>.<region>.migetapp.com - the app name gets a random suffix appended, so inbox-api becomes something like inbox-api-y3c3k.
GET https://inbox-api-y3c3k.eu-east-1.migetapp.com/health → 200 {"status":"UP"}
GET https://inbox-web-rhtqc.eu-east-1.migetapp.com/en → 200
The web returned a transient 502 right after deploy - the node was still booting. A minute later, 200.
/health returning UP is the real signal. The Spring health endpoint includes the readiness group, which checks the database. A single 200 proves the whole chain: GHCR pull worked, container started, Flyway migrated the real Postgres, HTTP is responding.
Step 6 - Logs live somewhere else
When the agent went to check the deployment logs, they were empty:
GET /apps/<API_APP>/deployments/<DEPLOY_ID>/logs
# → []
This is by design. For container_registry deploys there is no build phase, so there are no build logs. Runtime logs go to a separate Loki instance at https://metrics.miget.com, using the same Bearer token.
# find your service label first
curl -s -G https://metrics.miget.com/loki/api/v1/label/service_name/values \
-H "Authorization: Bearer $T"
# then query the last 30 minutes of logs
curl -s -G https://metrics.miget.com/loki/api/v1/query_range \
-H "Authorization: Bearer $T" \
--data-urlencode 'query={service_name="inbox-api"}' \
--data-urlencode "start=\((( (\)(date +%s)-1800)*1000000000 ))" \
--data-urlencode "end=\((( \)(date +%s)*1000000000 ))" \
--data-urlencode 'limit=400' \
--data-urlencode 'direction=forward'
Useful LogQL filters: |= "error", |~ "timeout|refused", != "/health" (to stop health-check noise), | json | level="error". The Miget docs for this are at https://docs.miget.com/monitoring/logs and the UI's Monitoring → Logs section does the same thing.
What landed
The final state after the agent finished:
API:
https://inbox-api-y3c3k.eu-east-1.migetapp.com- imageapi:sha-472a9ea,/healthreturnsUPWeb:
https://inbox-web-rhtqc.eu-east-1.migetapp.com/en- 200Database: reused
postgres-service-d7eqa, default database, 4 Flyway schemasPrivate pull: via Miget registry credential
ghcr-inbox
Gotchas
There were 8 things that were not obvious. The agent figured all of them out on its own. I'm listing them here so you don't have to.
Private GHCR needs a registry credential. Fast fail with no logs is the sign.
credential_idonly accepted on app creation.PUTsilently drops it. The fix is delete and recreate. The credential works even though the API doesn't echo it back.Port 5000 is fixed. Override with
SERVER_PORT=5000/PORT=5000.CREATEDB is denied for the service user. Reuse the default database, isolate with schemas.
Internal vs external DB host. Apps on the node use
.migetapp.internal. Your laptop uses.onmiget.com.Runtime logs are in Loki at
metrics.miget.com. Build logs are empty for registry deploys.RAM is tight. 1 GB node, Postgres eats ~256 MB, API gets 512 MB, web gets 256 MB. That's exactly the budget - zero headroom.
CI images only publish from
main. Taggedsha-<commit>. Deploy by the sha tag, notlatest.
What's not done yet
The skeleton proves connectivity. It does not prove the product.
Web is not wired to the API.
NEXT_PUBLIC_API_BASE_URLis baked into the Next.js image at build time. Pointing the frontend at the deployed API URL requires a rebuild with the production URL. Right now each piece starts and responds - they are not yet talking to each other.Shared database. No dedicated
inboxdatabase - schema isolation only. Fine for now.No headroom on the node. Adding any more services will require a bigger plan first.
Deploy by digest, not by sha tag. The sha tag is human-friendly but not reproducibility-hardened. Next step is deploying by image digest.
Rotate the tokens. The Miget API token and the GHCR PAT used during setup should be rotated now that the skeleton is stable.
On cost
It's worth mentioning for anyone running small projects: two private containers plus a managed Postgres on a single 1vcpu and 1 GB RAM node on Miget costs a lot less than the same setup on the big clouds. For a walking skeleton in a bootstrapped project, that gap matters. I won't put a number here because pricing changes, but it is worth checking against what you're paying now.
That's the full walkthrough. The skeleton is running. The next step is wiring the frontend to the API and getting the first real user flow working end-to-end. We will see how that goes.
Let me know what you think - especially if you ran into different walls on Miget.
