Serverless & Container PaaS
Managed container platforms such as Render, Fly.io, Railway, Koyeb and Zeabur give you a single container, an ephemeral filesystem and no host access. Since 0.26 PicFast ships a slim image built for this shape — pure Go, no libvips, about 13 MB compressed, and much faster cold starts.
This guide covers the three things every one of these platforms needs, the image choice, per-platform recipes, free-tier notes and the checks to run before going live. For Docker Compose, reverse proxies and TLS, see Deployment.
In one sentence: instance disks are disposable and instances can be reclaimed at any time, so PicFast needs external PostgreSQL, S3-compatible object storage and an external job trigger.
1. The three prerequisites
1.1 External PostgreSQL
The platform only runs the PicFast process, so the database must be a managed PostgreSQL service:
PICFAST_DATABASE_URL=postgres://user:pass@host:5432/picfast?sslmode=require - Free without a credit card: Neon (1 GB storage + 100 CU-hours per month, scales to zero when idle, and the free plan does not bill overages), Aiven (free forever, no card) and Prisma Postgres (free forever, no card). Render's own free PostgreSQL expires after 30 days — do not use it for a long-lived deployment.
- Background jobs can eat the free compute allowance: the built-in worker polls the database
every 5 seconds (
webhook.worker_interval). While the instance is online, a database that scales to zero on idle (such as Neon) never gets to sleep and burns its monthly compute. SetPICFAST_JOBS_EMBEDDED=falseand drive jobs externally (see 1.3) if that becomes a problem. - Transaction-mode poolers (Supabase
:6543, Neon-pooler, pgbouncer) needdefault_query_exec_mode=execappended to the connection string. pgx statement caching is not reliable behind a transaction pooler. Migrations must use the direct connection string (see Migrations). -
Keep
pool_max_connsat 3 or more: a background run holds one connection for its advisory lock while other queries need connections. PicFast prints a warning at startup when the pool is too small. -
PicFast connects at startup and exits if
pool.Pingfails. If a managed database suspends when idle, the first request after a cold start can fail once while the database wakes up. Keep the database always-on, or run at least one app instance.
1.2 S3-compatible object storage
These platforms use an ephemeral container filesystem: restarts, redeploys and instance reclamation all drop it. No platform lets several instances share one volume either, so local storage cannot support more than one replica. Use S3-compatible storage — Cloudflare R2, TOS, OBS, Kodo, OSS, COS or MinIO.
- Free without a credit card: Backblaze B2 (first 10 GB), Tigris (5 GB) and Neon Object Storage
(5 GB on the same Neon account; path-style and SigV4 only). For Neon, either enable public read in the Neon
console or set the strategy's
link_modetoproxyand let PicFast serve the images. - Cloudflare R2 requires a payment method before it can be enabled. Avoid it if you are deliberately staying card-free.
On first boot (when the database has no storage strategy yet) you can create the default strategy from environment variables:
PICFAST_STORAGE_DEFAULT_TYPE=s3
PICFAST_STORAGE_DEFAULT_CONFIG='{"endpoint":"https://s3.example.com","region":"auto","bucket":"pics","access_key":"AK","secret_key":"SK","url":"https://cdn.example.com"}' Cloudflare R2 uses the S3-compatible type as well:
PICFAST_STORAGE_DEFAULT_TYPE=s3
PICFAST_STORAGE_DEFAULT_CONFIG='{"endpoint":"https://<account>.r2.cloudflarestorage.com","region":"auto","bucket":"pics","access_key":"AK","secret_key":"SK","use_path_style":true}' These variables apply only when the database has no storage strategy. If a strategy already exists they are ignored and a WARN is logged. In that case:
- Recommended: add an S3/R2 strategy under Storage Strategies in the admin console and make it the group default. Existing strategies and historical images are unaffected.
- If the database has never stored an image and you want to keep using environment variables, run
delete from strategies;and restart. Side effects:group_strategiesis deleted by cascade andimages.strategy_idis set toNULL, so do not do this when there is history.- Or convert the existing row in place, preserving group mappings and image references:
update strategies set name='Default Storage', strategy_type='s3', configs='...'::jsonb where id = 1;Empty and malformed values behave differently: setting only
PICFAST_STORAGE_DEFAULT_TYPE=s3with an emptyPICFAST_STORAGE_DEFAULT_CONFIGfalls back to local storage with a WARN (so a platform create screen can offer it as optional), while invalid JSON refuses to start. That fail-fast behavior is deliberate: silently treating a typo as “use the local disk” would lose images.
1.3 External job trigger
On a long-running deployment, webhook delivery and hourly cleanup run inside the process. On platforms that reclaim instances after a response, disable the embedded scheduler and trigger jobs from outside:
PICFAST_JOBS_EMBEDDED=false
PICFAST_JOBS_TOKEN=<random long token> # omit to leave the HTTP endpoint unregistered Two equivalent triggers, depending on what the platform supports:
- HTTP:
POST /internal/jobs/runwithAuthorization: Bearer $PICFAST_JOBS_TOKEN. It runs one round synchronously and returns a JSON report (500 when a task fails). The endpoint is rate-limited per source IP at 10 requests per minute. - CLI:
picfast jobs runin the same image. It runs one round, prints the report as JSON and exits with code 1 on failure.
Trigger it once per minute: webhook delivery latency equals the trigger interval. Concurrent
triggers from several instances are safe — jobs serialize on PostgreSQL advisory locks and duplicates are
reported as skipped.
2. Choose the image
| Tag | What you get |
|---|---|
latest / v0.26.0 | Full image (Debian slim + libvips + pg_dump): WebP output, text watermark,
and maintenance backup/restore. Larger, slower cold start.
|
slim / v0.26.0-slim / 0.26-slim | Slim image (pure Go, distroless, no CGO): about 13 MB compressed (the full image is about 120 MB), fast cold start. Recommended for serverless and container PaaS. |
Before deploying, confirm that you can live with the slim image's differences:
- No WebP output and no text watermark (the console hides options the server cannot apply).
-
No
pg_dump, so maintenance backup/restore in the admin console is unavailable. Use the full image, or dump the database / take snapshots externally. - Re-encoding does not preserve EXIF metadata, even when “strip EXIF” is off.
-
WebP sources cannot be resized or compressed on the fly:
@w_/@q_variants on a.webpfile return the original (WebP thumbnails still work).
No shell: how to pass commands
The slim image is distroless — there is no shell and no package manager. Anything that runs through a shell
fails: sh -c '...', bash -c, &&, pipes and redirection. The working
form is to exec the binary directly, i.e. “override Docker CMD, append
arguments to the image ENTRYPOINT”. In that case a migration command is migrate
and a jobs command is jobs run.
When a platform treats the command as a full command line, write the complete path:
/usr/local/bin/picfast migrate (the binary lives there in both images). The two forms differ by one
prefix. To tell which one your platform uses, read the deploy log: unknown command "/usr/local/bin/picfast"
means CMD semantics (drop the path), while executable file not found means you need the full path.
Memory: slim decodes with Go's standard library, so the whole bitmap is held in memory —
roughly 3 × width × height × 4 bytes per decode. Single-image decoding is capped at
64 megapixels (about 750 MB in the worst case), with at most 2 concurrent decodes. When image processing is on,
give the instance at least 1 GB. On 256–512 MB instances, disable processing or lower the
upload cap:
PICFAST_APP_SKIP_IMAGE_PROCESSING=true # site settings in the console win over this env var
PICFAST_APP_MAX_UPLOAD_BYTES=10485760 # default is 52428800 (50 MiB)
PicFast's own request limits are a 30s read timeout, a 60s write timeout and a 60s request-timeout middleware
(adjustable via server.read_timeout). Set the platform request timeout to 60s or more;
a longer platform timeout buys nothing.
For production, use a fixed version tag such as v0.26.0-slim. Rolling tags like
slim or 0.26-slim move with each release, and most PaaS platforms do not redeploy when
an image tag changes (Render and Koyeb both require a manual deploy).
3. Minimal environment
# Required
PICFAST_DATABASE_URL=postgres://user:pass@host:5432/picfast?sslmode=require
PICFAST_JWT_SECRET=<strong random secret>
PICFAST_SERVER_BASE_URL=https://pics.example.com
# Port: PICFAST_SERVER_PORT > platform PORT > 8080
# PICFAST_SERVER_PORT=8080
# Recommended on PaaS
PICFAST_SERVER_METRICS_ENABLED=false
PICFAST_JOBS_EMBEDDED=false
PICFAST_JOBS_TOKEN=<random long token>
PICFAST_DATABASE_AUTO_MIGRATE=false
# First-boot storage and headless admin
PICFAST_STORAGE_DEFAULT_TYPE=s3
PICFAST_STORAGE_DEFAULT_CONFIG='{"endpoint":"...","region":"auto","bucket":"...","access_key":"...","secret_key":"...","link_mode":"proxy"}'
PICFAST_APP_ADMIN_EMAIL=admin@example.com
PICFAST_APP_ADMIN_PASSWORD=<strong password> - Port precedence:
PICFAST_SERVER_PORT> the platform-injectedPORT>8080. If the platform injectsPORT, PicFast follows it automatically; on Fly.io (which does not inject it) use the default 8080 or setPICFAST_SERVER_PORTexplicitly. - Site settings override environment variables: once site name, site URL, registration/guest
upload switches or image-processing settings are saved in the admin console, they are persisted in the database
and the corresponding
PICFAST_APP_*/PICFAST_SERVER_BASE_URLvariables stop taking effect. Use environment variables to bootstrap the first deploy, then change settings in the console. -
Without
PICFAST_APP_ADMIN_EMAILandPICFAST_APP_ADMIN_PASSWORD, the first visit opens the setup wizard. Set both for headless deployments.
Health probes are served at the root and need no authentication:
| Endpoint | Purpose |
|---|---|
GET /healthz | Liveness. Always returns 200 and does not touch the database or disk. |
GET /readyz | Readiness. Checks database connectivity only; returns 503 on failure. |
GET /health |
Legacy. Also checks that storage.local_root is writable — point it at a writable path
(for example under /tmp) if you still use it as a probe.
|
4. Platform recipes
The table is a quick orientation; the sections below have the commands, probes, hooks and caveats. Platform details change, so check the vendor documentation before you commit to a plan.
| Platform | Image | Port | Migrations | Scheduled jobs | Scale to zero |
|---|---|---|---|---|---|
| Render | prebuilt image | PORT, default 10000 | preDeployCommand (paid) or startup auto-migrate | Cron Jobs service (paid) | Free plan only |
| Fly.io | [build] image in fly.toml | internal_port, default 8080 | [deploy] release_command | fly machine run --schedule (hourly at the finest) | yes (auto_stop_machines) |
| Railway | Docker Image service | PORT injected | Pre-deploy Command | per-service cron, min. 5 minutes | yes (serverless) |
| Koyeb | Docker service | PORT = lowest exposed port | koyeb services exec | not documented — external scheduler | yes (preview) |
| Zeabur | Docker Images service | declare the port yourself | console Command | not documented — external scheduler | no |
4.1 Render (one-click, Free plan, no credit card)
The button reads render.yaml
from the repository and creates a single Free web service running the prebuilt slim image —
no build, no registry and no credit card. Render asks for one thing during creation: a managed PostgreSQL
connection string. Neon is a good fit (free forever,
no card; append default_query_exec_mode=exec when using its pooled string).
- Open the site and complete the setup wizard.
- Set Settings → Site URL to the assigned domain (or your custom domain).
- Add an S3-compatible strategy under Storage Strategies — Backblaze B2, Tigris and Neon Object Storage all work without a card. The platform disk is ephemeral, so uploads left on local storage are lost when the instance is replaced.
Free-plan trade-offs: 0.1 CPU / 512 MB RAM; the service sleeps after 15 minutes without inbound traffic and takes about a minute to wake; 750 instance hours per month; background jobs pause while it sleeps. The blueprint lowers the per-file upload cap to 10 MiB so a 512 MB instance is not pushed into OOM while decoding. Upgrade to a paid plan when you need scheduled jobs, more memory or a managed database.
- Port: Render injects
PORTand a web service expects 10000 by default. PicFast followsPORTautomatically and must bind0.0.0.0; do not setPICFAST_SERVER_PORTto a different value at the same time. - Health check: TCP by default. With
healthCheckPathset, Render uses HTTP and treats 2xx/3xx as healthy, requiring a response within 5 seconds. After 15 seconds of failures it stops routing traffic; after 60 seconds it restarts the instance. - Database: the blueprint intentionally does not create Render PostgreSQL (the free instance
expires after 30 days). Paste a Neon/Aiven connection string when creating the service; append
default_query_exec_mode=execto pooled strings. - Migrations: on the free plan keep startup auto-migration (concurrent instances serialize on an
advisory lock). On a paid plan you can use
preDeployCommand: migrateinstead; it runs once in a separate instance before deployment, and the command follows the “override CMD” semantics described above. - Cron or more memory: Render cron has no free plan. Upgrade and add a cron service, then set
PICFAST_JOBS_EMBEDDED=falseon the web service to avoid duplicate runs:
- type: cron
name: picfast-jobs
runtime: image
image:
url: docker.io/xbeta/picfast:slim
schedule: "*/5 * * * *" # UTC
dockerCommand: jobs run # overrides the image CMD
envVars:
- key: PICFAST_DATABASE_URL
sync: false Docker Command semantics: it overrides the Dockerfile CMD and appends arguments to
the image ENTRYPOINT, so jobs run is enough. Render's documentation suggests joining
multiple commands with /bin/sh -c — the slim image has no shell, so that does not work. Image
services also do not redeploy when a tag moves (including latest); trigger a deploy manually.
4.2 Fly.io
Fly Machines are long-running VMs, but auto_stop_machines / auto_start_machines can stop
them when idle and wake them on request (roughly 2-second cold start; suspend is faster). Together
with the slim image that makes an on-demand container deployment.
Put a fly.toml in a directory (no repository clone needed):
app = "picfast"
primary_region = "sin" # pick a region close to you
[build]
image = "docker.io/xbeta/picfast:v0.26.0-slim" # prebuilt public image, no local build
[env]
PICFAST_SERVER_BASE_URL = "https://picfast.fly.dev"
PICFAST_SERVER_METRICS_ENABLED = "false"
PICFAST_JOBS_EMBEDDED = "false"
PICFAST_DATABASE_AUTO_MIGRATE = "false"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[http_service.concurrency]
type = "requests"
soft_limit = 200
[[http_service.checks]] # probe readiness; the path must return 2xx directly (Fly does not follow redirects)
interval = "15s"
timeout = "5s"
grace_period = "10s"
method = "get"
path = "/readyz"
[http_service.checks.headers]
X-Forwarded-Proto = "https" # with force_https, keeps the probe from being redirected
[deploy]
release_command = "migrate" # overrides CMD; the image ENTRYPOINT still runs → picfast migrate
[[vm]]
size = "shared-cpu-2x"
memory = "1gb" # image processing needs memory, see section 2 fly apps create picfast # first time only; image and config come from fly.toml
fly secrets set \
PICFAST_DATABASE_URL='postgres://...' \
PICFAST_JWT_SECRET='...' \
PICFAST_JOBS_TOKEN='...'
fly deploy - Migrations:
[deploy] release_command = "migrate"starts a temporary Machine before each deploy and runs/usr/local/bin/picfast migrate; a failure aborts the deploy. Fly's semantics are “override CMD, keep ENTRYPOINT”, so the bare subcommand is correct. The temporary Machine has network access and secrets, but no volume. - Scheduled jobs:
fly machine run xbeta/picfast:v0.26.0-slim --app picfast --schedule hourly jobs runonly supports hourly/daily/weekly/monthly. For minute-level webhook delivery, callPOST /internal/jobs/runfrom an external scheduler (GitHub Actions is about 5 minutes at best; a crontab can do every minute), or keepPICFAST_JOBS_EMBEDDED=trueand let the app stay resident. Scheduled Machines are separate one-off instances: pass the variables they need with-e PICFAST_DATABASE_URL=...; they do not mount a volume and do not start the app process for you. - Note: with
auto_stop_machines = "stop", background jobs stop with the instance, so disabling the embedded scheduler and triggering externally is more predictable.
4.3 Railway
- New Project → Empty Project → Add a Service → Docker Image, image
xbeta/picfast:v0.26.0-slim. -
Add the variables from section 3 (
PICFAST_SERVER_PORT=8080is optional; Railway injectsPORTand PicFast follows it). -
Settings → Networking → Generate Domain (gives you
*.up.railway.appwith automatic HTTPS). Point the target port at 8080; Railway also probes the listening port when the domain is first generated. -
Health check:
/readyz(any 2xx succeeds; default timeout 300s). Health-check requests come fromhealthcheck.railway.app, so the app must accept that Host. -
Migrations: set Pre-deploy Command to
migrateandPICFAST_DATABASE_AUTO_MIGRATE=false. It runs in a separate container with no volume and aborts the deploy on failure. If the log says the executable was not found, use/usr/local/bin/picfast migrate(see section 2). You can also skip the hook and migrate from outside (see Migrations). -
Scheduled jobs: add another service from the same image, set Settings →
Cron Schedule to
*/5 * * * *and the start command tojobs run(again “override CMD” semantics; use the full path if the executable is not found). Railway's minimum cron interval is 5 minutes, and the process must exit after each run (picfast jobs rundoes; a run that is still going causes the next one to be skipped). For every-minute delivery, callPOST /internal/jobs/runfrom an external scheduler.
Railway caveats:
- HTTP request bodies must finish uploading within 5 minutes; idle connections without data are closed after 5 minutes; request headers are capped at 32 KB.
- POST requests over port 80 are rewritten to GET. Always use the HTTPS URL.
- With Serverless enabled (scale to zero), the service sleeps after 5–10 idle minutes and the first request after waking can return 502. For an image host that is linked from anywhere, keep at least one replica running or accept the occasional 502.
- Config as Code (
railway.json/railway.toml) is marked deprecated; prefer the console or Infrastructure as Code for new deployments.
4.4 Koyeb
koyeb services create picfast \
--docker docker.io/xbeta/picfast:v0.26.0-slim \
--type web \
--regions fra \
--instance-type small \
--ports 8080:http \
--min-scale 0 --max-scale 1 \
--env PICFAST_SERVER_BASE_URL=https://picfast-<org>.koyeb.app \
--env PICFAST_SERVER_METRICS_ENABLED=false \
--env PICFAST_JOBS_EMBEDDED=false \
--env PICFAST_DATABASE_AUTO_MIGRATE=false \
--env PICFAST_DATABASE_URL='{{secret.picfast_db_url}}' \
--env PICFAST_JWT_SECRET='{{secret.picfast_jwt}}' (Argument names depend on your koyeb CLI version; the console equivalent is “Create Service → Docker”.)
- Port: Koyeb defines
PORT(the lowest exposed port when not set explicitly). Ports are closed by default and must be exposed (--ports 8080:http). - Health check: TCP by default; an HTTP path can be configured on the port (2xx/3xx is healthy). Traffic only switches after all exposed ports pass, otherwise the last healthy version stays up.
- Migrations: there is no release hook. Run
koyeb services exec picfast /usr/local/bin/picfast migrateinside the instance (ormigrateif the platform strips the path — see section 2), or migrate from outside (see Migrations). - Scheduled jobs: Koyeb does not document cron. Call
POST /internal/jobs/runfrom an external scheduler (GitHub Actions, Cloudflare Worker Cron, a crontab) or keepPICFAST_JOBS_EMBEDDEDat its default and keep the instance resident. - Scale to zero: set the minimum instance count to 0. It is a preview feature and HTTP/2 cannot wake a sleeping service (use HTTP/1.1 or a normal HTTPS refresh); waking takes about 1–5 seconds.
- Volumes: still a preview (only
was/fra, 1–10 GB, exactly one replica). Do not use one for image data.
4.5 Zeabur
- Create a project → Add Service → Docker Images, image
xbeta/picfast:v0.26.0-slim. - Add the variables from section 3 under Environment Variables (you can reference other project variables with
${KEY}). -
Add a port: container port (for example 8080), type HTTP. Only declared ports are reachable;
HTTP ports get a TLS domain automatically (
*.zeabur.app, or a custom domain). - Settings → Health Check:
/readyz(TCP by default; a custom path must return 2xx). - Migrations: run
/usr/local/bin/picfast migratefrom the service Overview → Command (orjobs runto run one round of background jobs manually). - Scheduled jobs: Zeabur does not document cron. Call
POST /internal/jobs/runfrom an external scheduler, or let the embedded scheduler stay on (PICFAST_JOBS_EMBEDDED=true) and keep the service resident. - Scaling: Zeabur only scales vertically and cannot scale to zero. Suspend the service manually to stop billing.
Zeabur caveats:
- Health checks run every 10s with a 5s timeout; three consecutive failures mark the service unhealthy. The old version keeps serving until the new one passes (with a volume attached it is stop-then-start, so expect a short outage).
- Volumes cannot be shared across services (reusing a Volume ID creates a new one). Use object storage when data must be shared — exactly what section 1.2 describes.
- When proxying through Cloudflare, do not use Full (Strict); certificate validation can fail.
5. Free tiers and no-card options
Verified against vendor docs in October 2026. Free tiers and promotions change often — confirm before you rely on one.
| Platform | Credit card? | Free allowance | Notes |
|---|---|---|---|
| Render | No for the Free plan | 750 instance hours/month, 0.1 CPU / 512 MB | No free cron; free PostgreSQL expires after 30 days; sleeps after 15 idle minutes and takes about a minute to wake. |
| Railway | No (“No credit card required”) | 30-day $5 trial, then Free at $1/month | Native cron, 5 minutes at the finest; the first request after sleep may return 502. |
| Fly.io | Trial no, afterwards yes | 2 machine hours or 7 days | Apps stop running without a card. |
| Koyeb | Yes | New users can only pick Pro or above, with a $29 pre-authorization | The free tier is still documented but not reachable for new users. |
| Northflank | Yes, on every plan | Dev Sandbox: 2 services / 2 jobs / 1 addon | Official docs require a payment method first. |
| Zeabur | No | Free means “manage your own servers” | The shared cluster is closed to new projects, so there is effectively no free compute. |
The smallest card-free stack is Render Free (container) + Neon (PostgreSQL, free forever) + Backblaze B2 or Neon Object Storage — none of the three needs a card, and Neon alone can provide both PostgreSQL and 5 GB of object storage. When you need on-time cron or more memory, move to Railway (native cron, 5 minutes) or a paid Render plan.
China platforms
Chinese platforms require real-name verification rather than an international credit card; payment works through WeChat/Alipay. Whether the platform can pull a public Docker Hub image is usually the deciding factor:
| Platform | Public image? | Free allowance | Main caveats |
|---|---|---|---|
| Tencent CloudBase Run | Yes — Docker Hub is documented | Free trial at 0 CNY/month plus 3000 resource points/month (renewable for 6 months; roughly 37.5 hours of 1C2G or about 6.5 GB egress) | Three official documents disagree on whether the free trial can create the service — test it. The quota is shared across products (cloud storage/CDN traffic costs 460 points/GB). Official docs say do not start background threads or processes outside the request handler, so disable the embedded scheduler. |
| Alibaba Cloud Function Compute 3.0 | No — requires an ACR image in the same account and region | 150k CU per cycle, for 3 consecutive cycles |
Overage switches to pay-as-you-go automatically. It does not inject PORT (only
FC_CUSTOM_LISTEN_PORT): map the listen port (for example 9000) to 8080 and align it with
PICFAST_SERVER_PORT.
|
| Volcengine veFaaS | No — push to a registry in the same region (the CR trial ended) | None — the new-user trial package ended in May 2024 | Web functions default to port 8000, so set PICFAST_SERVER_PORT=8000; background processes are discouraged; there is no zero-cost path. |
| Tencent SCF (container image) | No — requires TCR (the personal edition is free; retagging works) | First 3 months: 1M invocations + 1M GB-seconds + 2 GB egress per month; pay-as-you-go afterwards | Port is fixed at 9000 and the HTTP server must be ready within 30 seconds. Suitable as a fallback or for scheduled jobs, not as the main web entry point. |
| Huawei Cloud FunctionGraph | No — requires SWR (the basic edition is free) | Long-term monthly: ≤1M requests, ≤400k GB-seconds | Port is fixed at 8000. HTTP functions do not support scheduled triggers, so background jobs need event functions. |
| Baidu AI Cloud CFC | No — there is no container-image entry point (zip only) | New-user package for 3 months | Effectively out for containerized serverless. |
- CloudBase is the best technical fit: the only Chinese platform that pulls public Docker Hub
images, it injects
PORTnatively (the value always reflects the port the request arrived on), its 60s request timeout matches PicFast'sWriteTimeout, and it includes PostgreSQL and cloud storage reachable over the internal network — no external database or object storage required. Tencent does not publish a cold-start figure (only that it depends on resources, image size and startup time; the widely quoted “30 seconds” is SCF's startup timeout, not CloudBase's). The catch is confirming that the free trial can create the service at all, and the quota only covers demos and functional testing. - Object storage free tiers in China: JD Cloud OSS is the only one that grants credit every month (10 GB standard storage per region per month, valid for that month), and its trial zone also has a new-user 50 GB / 6-month package plus 10 GB per month — more generous than Tencent COS (one-off 50 GB / 180 days) for the first half year. Alibaba OSS is a one-off 20 GB / 3 months; Volcengine TOS, Huawei OBS and Baidu BOS have no free tier; QingStor uses long-term tiered pricing (0–10 GB storage and 0–1 GB egress free). JD Cloud's free zone has no function compute, JKE, JCR or RDS, so do not look for container products there. For PostgreSQL, besides CloudBase's built-in database, Alibaba RDS PostgreSQL only offers a 1-month trial and starts charging automatically when it ends (release it manually); PolarDB's “first three months free” ended in June 2025.
- Other Chinese vendors are not usable (worth knowing so you do not try): JD Cloud Function Service only supports Node.js 16, asynchronous invocation, no HTTP/cron trigger and no public network, so it cannot host PicFast. JD Cloud's product called “cloud hosting” is actually IDC rack hosting, not container hosting (its container products are Native Container / JKE / JCR, and Native Container requires a balance above 50 CNY). QingCloud has no serverless or function-compute product at all. Neither requires a credit card, but neither offers usable long-term free compute. For free PostgreSQL, Huawei, Baidu, JD and QingCloud have none (Baidu's free GaiaDB is MySQL-compatible and does not work with PicFast's PostgreSQL).
- None of these vendors has a one-click entry point equivalent to Render's button. Prepare migrations as described in Migrations (for example run
picfast migratefrom CI over the direct connection string). - The quotas and limits above were checked against official docs in October 2026, often on promotional pages. Re-check before you start.
6. Client IP and trusted proxies
PicFast reads X-Forwarded-For, X-Real-IP and X-Forwarded-Proto to get the
real client IP for per-IP rate limiting, audit logs and the login cookie's Secure flag.
The rules live in internal/clientip:
- Headers are only read when the TCP peer (what the container sees) is trusted. Loopback, private ranges (10/8, 172.16/12, 192.168/16, IPv6 ULA) and link-local addresses are trusted by default, no configuration needed. Most platform ingress proxies connect from one of those, so this usually works out of the box.
-
Resolution order:
CF-Connecting-IP→ the rightmost untrusted address inX-Forwarded-For→X-Real-IP→ the connection address.X-Forwarded-Foris scanned right to left for the first address not in the trusted list, so the list should contain proxy hops only.
When the proxy source is a public address, configure comma-separated CIDRs:
PICFAST_SERVER_TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12,fc00::/7" Do not use
0.0.0.0/0. It treats every address as a trusted proxy hop, soX-Forwarded-Forno longer contains a usable client address and PicFast falls back toX-Real-IPor the proxy address — useless for per-IP rate limiting. Add only the ranges the platform's proxy actually uses.
| Platform | Client IP situation |
|---|---|
| Fly.io |
Fly also provides Fly-Client-IP (PicFast does not read it) and appends your app's own public IP
to the end of X-Forwarded-For. Because the right side wins, PicFast picks that public IP. Fix:
add the app's public IPs from fly ips list to PICFAST_SERVER_TRUSTED_PROXIES; the
right-hand entry is then skipped and the real client IP surfaces.
|
| Railway |
Documents X-Real-IP as the client IP and always sets X-Forwarded-Proto: https.
X-Forwarded-For is not in its documented header list, so PicFast falls back to
X-Real-IP when XFF has no usable address.
|
| Render / Koyeb / Zeabur | Forwarding behavior is undocumented and there are no published proxy ranges. Verify first, then decide whether to configure anything. |
How to verify: check the source IP in the admin audit log, or send enough requests to one endpoint
to trigger rate limiting (the limiter counts per instance). If every visitor appears to come from one address, the
proxy address is leaking through. If you cannot get the real IP, add the proxy range from the vendor docs to
trusted_proxies. Also make sure the site URL is https://; otherwise the login cookie
will not carry Secure (which is decided by a trusted X-Forwarded-Proto: https or direct
TLS).
7. Database migrations
Migrations are embedded in the binary. Pick one of three approaches:
- Automatic on startup (default): every instance tries to migrate at boot. Concurrent instances are safe: golang-migrate serializes on a PostgreSQL session-level advisory lock, one instance applies the migration and the others wait, find nothing to do and continue. The cost is a little extra cold-start time and one database connection.
- Platform release hook: Fly's
[deploy] release_command, Railway's Pre-deploy Command and Render'spreDeployCommandrunpicfast migrateonce before deployment (setPICFAST_DATABASE_AUTO_MIGRATE=falseat the same time). Fly overrides CMD and keeps ENTRYPOINT, so usemigrate; on the others, use the full path/usr/local/bin/picfast migrateif the executable is not found (see section 2). The slim image has no shell, so do not writesh -c "picfast migrate"ora && b. Koyeb and Zeabur have no release hook; run the command inside the instance instead. - From your machine or CI: no platform feature required — run the same image with the direct connection string:
PICFAST_DATABASE_URL='postgres://user:pass@direct-host:5432/picfast?sslmode=require' \
docker run --rm -e PICFAST_DATABASE_URL xbeta/picfast:v0.26.0-slim migrate Pooled connection strings cannot run migrations: the migration lock is session-level and a transaction-mode pooler (Supabase
:6543, Neon-pooler) cannot hold it reliably. Use a direct or session-mode string for migrations; the app itself can use a pooled string withdefault_query_exec_mode=exec.
If the application account cannot create or alter tables (some managed PostgreSQL plans hand out restricted
accounts), keep PICFAST_DATABASE_AUTO_MIGRATE=false and run picfast migrate separately
with an account that has DDL rights.
8. Go-live checklist
# 1) Probes
curl -fsS https://pics.example.com/healthz # expect 200 {"status":"ok"}
curl -fsS https://pics.example.com/readyz # expect 200 (503 when the database is unreachable)
# 2) Upload an image and check object storage and thumbnails
# The URL should point at your storage/CDN domain; /t/{md5}.png should return a thumbnail
curl -fsS -X POST https://pics.example.com/api/v1/upload \
-H "Authorization: Bearer YOUR_TOKEN" -F "file=@test.jpg"
# 3) Trigger one round of background jobs (the external scheduler uses the same path)
curl -fsS -X POST https://pics.example.com/internal/jobs/run \
-H "Authorization: Bearer $PICFAST_JOBS_TOKEN" Also confirm:
- Uploaded images are still reachable after an instance rebuild/redeploy (they are not on local disk).
- The console shows an S3/R2 strategy, not the default local storage.
- An external trigger is configured (or you accept the embedded scheduler while the instance is resident).
- The site URL is
https://and the login cookie carriesSecure. - If scale-to-zero is on, uploads and logins work after a cold start.
9. Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Uploaded images 404 after a redeploy | The default local storage is on the ephemeral disk. Configure S3/R2 (section 1.2) and upload again; export old files before the instance is reclaimed (there is no command to move objects between strategies; picfast maintenance doctor reports missing objects). |
| Instance exits at startup | Cannot reach the database. Check PICFAST_DATABASE_URL, sslmode, outbound 5432 access and whether the managed database is suspended. |
| First request after a cold start fails | The database is waking up or the instance just started. Use /readyz for probes, relax readiness, or set a minimum of one instance. |
| OOM while uploading a large image | Slim decodes with the standard library; peak memory is about 3× the pixel count (section 2). Raise memory to 1 GB or more, or disable processing / lower PICFAST_APP_MAX_UPLOAD_BYTES. |
| Upload rejected by the platform gateway (413/timeout) | Most platforms do not publish a body limit; PicFast allows 50 MiB by default. Lower PICFAST_APP_MAX_UPLOAD_BYTES and make sure the platform timeout is 60s or more. |
| Webhooks are not delivered for a long time | The embedded scheduler is off (PICFAST_JOBS_EMBEDDED=false) but no external trigger is configured, or the interval is too long. Delivery latency equals the trigger interval; trigger every minute. |
| Rate limits are stricter than configured / every log entry has the same IP | PicFast sees the platform proxy address. See section 6. |
@w_/@q_ variants of a .webp return the original | Slim cannot resize or compress WebP sources on the fly (section 2). Use the full image if you need it. |
| Migration reports “dirty” or the connection is interrupted | A transaction-mode pooled connection string was used. Run migrations over a direct/session-mode string (section 7). |
| Still on the old version after an upgrade | These platforms do not redeploy when an image tag moves (Render and Koyeb both require it). Trigger a deploy manually, or pin a version tag and make redeploying explicit. |
| The one-click deploy asks for a credit card | The old template included a cron service and a paid plan (Render cron has no free tier). The current render.yaml is Free-plan only and creates no cron or PostgreSQL, so no card is needed. |
| A 512 MB Free instance fails on slightly larger images | Pure-Go decoding peaks at roughly 3 × width × height × 4 bytes; the blueprint lowers the upload cap to 10 MiB. Lower PICFAST_APP_MAX_UPLOAD_BYTES further, disable image processing, or move to a plan with 1 GB or more. |
| The monthly free PostgreSQL allowance disappears quickly | The embedded worker polls the database every 5 seconds, which keeps an idle-scaling database such as Neon awake. Set PICFAST_JOBS_EMBEDDED=false and trigger jobs externally so the database can scale to zero after 5 idle minutes. |
| Webhooks stop after the instance has been idle | The Free plan sleeps after 15 minutes without inbound traffic, pausing the in-process scheduler. Upgrade and add a cron service, or call POST /internal/jobs/run from an external scheduler. |