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. Set PICFAST_JOBS_EMBEDDED=false and drive jobs externally (see 1.3) if that becomes a problem.
  • Transaction-mode poolers (Supabase :6543, Neon -pooler, pgbouncer) need default_query_exec_mode=exec appended 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_conns at 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.Ping fails. 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_mode to proxy and 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_strategies is deleted by cascade and images.strategy_id is set to NULL, 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=s3 with an empty PICFAST_STORAGE_DEFAULT_CONFIG falls 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/run with Authorization: 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 run in 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

TagWhat 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 .webp file 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-injected PORT > 8080. If the platform injects PORT, PicFast follows it automatically; on Fly.io (which does not inject it) use the default 8080 or set PICFAST_SERVER_PORT explicitly.
  • 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_URL variables stop taking effect. Use environment variables to bootstrap the first deploy, then change settings in the console.
  • Without PICFAST_APP_ADMIN_EMAIL and PICFAST_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:

EndpointPurpose
GET /healthzLiveness. Always returns 200 and does not touch the database or disk.
GET /readyzReadiness. 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.

PlatformImagePortMigrationsScheduled jobsScale 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)

Deploy to Render

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).

  1. Open the site and complete the setup wizard.
  2. Set Settings → Site URL to the assigned domain (or your custom domain).
  3. 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 PORT and a web service expects 10000 by default. PicFast follows PORT automatically and must bind 0.0.0.0; do not set PICFAST_SERVER_PORT to a different value at the same time.
  • Health check: TCP by default. With healthCheckPath set, 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=exec to 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: migrate instead; 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=false on 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 run only supports hourly/daily/weekly/monthly. For minute-level webhook delivery, call POST /internal/jobs/run from an external scheduler (GitHub Actions is about 5 minutes at best; a crontab can do every minute), or keep PICFAST_JOBS_EMBEDDED=true and 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

  1. New Project → Empty Project → Add a Service → Docker Image, image xbeta/picfast:v0.26.0-slim.
  2. Add the variables from section 3 (PICFAST_SERVER_PORT=8080 is optional; Railway injects PORT and PicFast follows it).
  3. Settings → Networking → Generate Domain (gives you *.up.railway.app with automatic HTTPS). Point the target port at 8080; Railway also probes the listening port when the domain is first generated.
  4. Health check: /readyz (any 2xx succeeds; default timeout 300s). Health-check requests come from healthcheck.railway.app, so the app must accept that Host.
  5. Migrations: set Pre-deploy Command to migrate and PICFAST_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).
  6. Scheduled jobs: add another service from the same image, set Settings → Cron Schedule to */5 * * * * and the start command to jobs 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 run does; a run that is still going causes the next one to be skipped). For every-minute delivery, call POST /internal/jobs/run from 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 migrate inside the instance (or migrate if 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/run from an external scheduler (GitHub Actions, Cloudflare Worker Cron, a crontab) or keep PICFAST_JOBS_EMBEDDED at 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

  1. Create a project → Add Service → Docker Images, image xbeta/picfast:v0.26.0-slim.
  2. Add the variables from section 3 under Environment Variables (you can reference other project variables with ${KEY}).
  3. 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).
  4. Settings → Health Check: /readyz (TCP by default; a custom path must return 2xx).
  5. Migrations: run /usr/local/bin/picfast migrate from the service Overview → Command (or jobs run to run one round of background jobs manually).
  6. Scheduled jobs: Zeabur does not document cron. Call POST /internal/jobs/run from an external scheduler, or let the embedded scheduler stay on (PICFAST_JOBS_EMBEDDED=true) and keep the service resident.
  7. 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.

PlatformCredit card?Free allowanceNotes
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:

PlatformPublic image?Free allowanceMain 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 PORT natively (the value always reflects the port the request arrived on), its 60s request timeout matches PicFast's WriteTimeout, 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 migrate from 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:

  1. 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.
  2. Resolution order: CF-Connecting-IP → the rightmost untrusted address in X-Forwarded-For → X-Real-IP → the connection address. X-Forwarded-For is 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, so X-Forwarded-For no longer contains a usable client address and PicFast falls back to X-Real-IP or the proxy address — useless for per-IP rate limiting. Add only the ranges the platform's proxy actually uses.

PlatformClient 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:

  1. 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.
  2. Platform release hook: Fly's [deploy] release_command, Railway's Pre-deploy Command and Render's preDeployCommand run picfast migrate once before deployment (set PICFAST_DATABASE_AUTO_MIGRATE=false at the same time). Fly overrides CMD and keeps ENTRYPOINT, so use migrate; on the others, use the full path /usr/local/bin/picfast migrate if the executable is not found (see section 2). The slim image has no shell, so do not write sh -c "picfast migrate" or a && b. Koyeb and Zeabur have no release hook; run the command inside the instance instead.
  3. 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 with default_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 carries Secure.
  • If scale-to-zero is on, uploads and logins work after a cold start.

9. Troubleshooting

SymptomCause 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.