Before the website
Ploy Fresh Fruit sells imported fruit and gift baskets. Most of their customers found them on their Facebook page and ordered there. They wanted a website of their own as one more way to sell, not a replacement for Facebook.
So now there are two sides: ployfreshfruit.com for customers, and LINE for the shop, where new orders show up and most of the daily admin work gets done.
What I built
There were three of us. These are the parts that were mine. A few dashboard pages use APIs my teammates wrote, and I built those pages and wired them up. I've done full-stack work on a lot of projects, so on this one I pushed myself on the DevOps side.
- frontend
Admin dashboard
- Overview + analytics
- Order management
- Product management
- User management
- backend
APIs
- Baskets CRUD
- Fruits CRUD
- Product & basket builder
- Admin overview APIs
- All image uploads to Cloudinary
- integration
LINE
- Messaging API, end to end
- New order alerts
- Rich Menu UI design
- Slip approval, prep and shipping flows
- devops
Deploy
- GitHub Actions build & push
- SSH deploy with Docker Compose
- Nginx + Certbot HTTPS for the domain
How the pieces fit

- Edge
- Nginx reverse proxy on a DigitalOcean droplet, with HTTPS certificates from Let's Encrypt via Certbot.
- Frontend
- Next.js, TypeScript and Tailwind CSS. Serves the storefront and the admin dashboard.
- Backend
- Express.js with TypeScript and Prisma ORM. All business logic, admin APIs and the LINE webhook.
- Database
- PostgreSQL, running in the same Docker Compose stack.
- Outside services
- Cloudinary for product images and payment slips, LINE Messaging API for the owner's Rich Menu, ThaiBulkSMS for OTP phone login.
The hardest part: baskets
This is the part that took the most thinking. A basket isn't one product with one price. Every set, like a birthday basket, has its own rules, and the same fruit can cost a different amount depending on which set it's in. I designed the Product APIs around that:
- Set price
- Each set has its own price.
- Fruit choices
- How many kinds of fruit the customer can pick, which fruits are allowed, and what each one costs in this set.
- Basket choices
- Which baskets the customer can choose for that set.
- Images
- Every image in the system is uploaded through the backend to Cloudinary, so the logic lives in one place.
Around it sit the Baskets and Fruits CRUD APIs, and the admin APIs that summarise sales for the overview page.
Why LINE, not just a dashboard
This was my idea. Opening a web dashboard every time isn't always convenient, and when a customer orders, the shop needs to know right away. So new orders send a message straight to LINE, and the everyday admin work happens from the Rich Menu. I built the whole LINE side and designed the menu.
- New order alerts
- A LINE message as soon as a customer places an order.
- Check & approve slips
- Payment slips show up in LINE, and the owner approves or rejects them from the Rich Menu.
- Orders to prepare
- See what still needs preparing, and mark an order as prepared once it's packed.
- Orders to ship
- See every order that hasn't gone out yet.
The admin dashboard
For anything bigger than a quick check, there's the web dashboard. It's built for a phone first.
- Overview
- Sales summary and analytics, backed by the admin APIs.
- Orders
- Track every order and its status.
- Products
- Create sets, open or close them for sale, edit prices.
- Users
- Manage customer and admin accounts.
The part I'm proudest of

I set up the whole pipeline by myself, and it failed a lot of times before it worked. Now every push to main builds both images, pushes them to Docker Hub, and deploys them to the droplet over SSH. Nobody logs in to the server by hand. Nginx and Certbot sit in front and keep HTTPS working for the domain.
Choices and what they cost
LINE as the admin interface
whyOpening the dashboard isn't always convenient, and new orders need to be seen right away.
costA Rich Menu is a small screen with limited UI, and the workflow depends on LINE's API.
One droplet with Docker Compose
whyCheap, simple, and the whole stack starts with one command.
costEverything runs on one machine, so there's no failover if it goes down.
Deploy over SSH from GitHub Actions
whyStraightforward to set up and easy to read.
costThe pipeline holds a key to the production server, so it has to be kept tightly scoped.
What I'd do differently
Ship with a way back.
The deploy stops the old containers before starting the new ones, so a bad release keeps the site down until I fix it by hand. Next time I'd start the new version first, health-check it, and roll back to the previous image automatically if the check fails.
Run migrations before switching traffic.
Right now the database migration runs after the new server is already live, so for a short window new code talks to the old schema. I'd make the migration its own step that runs first and stops the deploy if it fails.
Write tests for the order flow early.
I set up Vitest but never wrote the tests. The order and payment status flow (verify → receive → prepare → ship) is where a bug costs real money, so that's what I'd cover first, with a lint and type-check job that has to pass before any image is built.
Where it is now
- Status
- Live at ployfreshfruit.com ↗ (opens in a new tab)
- Catalog
- 30 products, 28 open for sale (Nov 2026)